//! 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::browser; 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::browser::{BrowserPanel, BrowserPanelEvent, BrowserWatch}; 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, Worktree, WorktreePlan}; use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleBrowser, 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, } 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, /// The device's name, for projects on other devices. pub host: Option, /// 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, /// The worktree the project's most recently active thread ran in, for /// the composer's "Previous worktree". pub previous_worktree: Option, } 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, 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>, /// `None` until read, or when the folder isn't a git repository. git: Option, /// The remote connection it's on, by id; `None` on this device. environment: Option, } /// A thread in the sidebar: its project's index and the session. type ThreadEntry = (usize, Entity); /// A settled thread, as the settings' archive lists it. pub struct SettledThread { pub session: Entity, 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, chats: HashMap>, /// Each thread's browser panel, made the first time it's opened. browsers: HashMap>, /// The browser panel showing, which watches its stream. browser_shown: Option, active: Option>, active_project: usize, search: Entity, sidebar_open: bool, panel_open: bool, /// The browser takes the project panel's place while open. browser_open: bool, settled_open: bool, snoozed_open: bool, working_open: bool, /// Projects folded away in the grouped thread list. collapsed_projects: HashSet, editors: Rc>, editor: usize, hostname: SharedString, settings_view: Entity, settings_open: bool, /// The Add project browser, while it's open. folder_browser: Option<(Entity, Subscription)>, activity: HashMap, /// The thread whose settle button waits for its confirming click. settle_armed: Option<(EntityId, Instant)>, /// Threads changed since they were last saved. dirty: HashSet, /// `state.json` as last written. saved_state: String, /// The git state of the open thread's worktree. worktree_git: HashMap, last_active: Instant, last_git: Instant, git_busy: bool, last_housekeeping: Instant, last_health: Instant, last_pr_check: Option, last_repaint: Instant, remote: RemoteState, focus_handle: FocusHandle, observers: HashMap>, _subscriptions: Vec, _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, window: &mut Window, cx: &mut Context) -> 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(), browsers: HashMap::new(), browser_shown: None, active: None, active_project: 0, search, sidebar_open: true, panel_open: true, browser_open: false, 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) -> Option { 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) { 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) { 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.git_busy = true; self.last_git = Instant::now(); let mut paths: Vec = 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::>() }); 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) { 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> = 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) { let threads: Vec<(Entity, 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::>() }); 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) { 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 { 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) { 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, ) -> Option> { 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, window: &mut Window, cx: &mut Context, ) -> Entity { 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, window: &mut Window, cx: &mut Context, ) -> Entity { 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, ) { 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, window: &mut Window, cx: &mut Context) { 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) -> Option { self.projects .iter() .position(|project| project.sessions.iter().any(|candidate| candidate == session)) } fn on_chat_event( &mut self, chat: &Entity, event: &ChatEvent, window: &mut Window, cx: &mut Context, ) { 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, create } => { let cwd = session.read(cx).cwd.clone(); self.switch_branch(cwd, branch.clone(), *create, window, cx); } ChatEvent::OpenProviderSettings => self.open_settings(window, cx), ChatEvent::ImplementInNewThread(plan) => { let settings = self.new_thread_settings(cx); if let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) { let prompt = format!("Implement this plan:\n\n{plan}"); chat.read(cx).session().clone().update(cx, |session, cx| session.send_message(prompt, Vec::new(), 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 { let mut order: Vec = (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, window: &mut Window, cx: &mut Context, ) { 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, cx: &mut Context) { 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, cx: &mut Context) { 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, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, cx: &mut Context, ) { 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); browser::stop(&browser::session_name(&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, cx: &mut Context) { session.update(cx, |session, _| session.shutdown()); let id = session.entity_id(); self.chats.remove(&id); self.browsers.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) { 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.folder_browser = None; self.focus_handle.focus(window, cx); cx.notify(); } fn prompt_with_system_dialog(&mut self, window: &mut Window, cx: &mut Context) { 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, Vec) { 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 { 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 { 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) { 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(); } /// Checks out a branch in a thread's folder (creating it first when /// `create`), then reads the project's git state again. fn switch_branch(&mut self, cwd: PathBuf, branch: String, create: bool, window: &mut Window, cx: &mut Context) { let project = self.active_project().map(|project| project.path.clone()); let task = cx.background_executor().spawn(async move { let output = if create { git::git(&cwd, &["switch", "-c", &branch]) } else { // `origin/x` checks out a local `x` tracking it. let local = branch.strip_prefix("origin/").unwrap_or(&branch); git::git(&cwd, &["switch", local]) }; let info = project.as_deref().and_then(git::read_info); (project, 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 { let message = if create { "Failed to create and switch ref." } else { "Failed to switch ref." }; window.push_notification(Notification::error(format!("{message} {err}")), cx); } if let Some(project) = this.projects.iter_mut().find(|project| Some(&project.path) == path.as_ref()) { 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) { 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.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.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.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) { 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 { 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, window: &mut Window, cx: &mut Context) { 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, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.project_of(&session) { self.delete_thread(ix, session, window, cx); } } // ---- sidebar -------------------------------------------------------- fn render_sidebar_header(&self, cx: &mut Context) -> 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) -> 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, session: Entity, menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> PopupMenu { let state = session.read(cx); let (pinned, settled, snoozed) = (state.pinned, state.settled, state.is_snoozed()); let action = |run: fn(&mut Workspace, Entity, &mut Window, &mut Context)| { 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, cx: &mut Context, ) -> 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), // Idle, but a background task will wake Claude. SessionStatus::Idle if !state.background_tasks.is_empty() => (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, ) -> 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) -> 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) -> 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 = 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) -> 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) -> 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 toggle_project_panel(&mut self, cx: &mut Context) { // With the browser in its place, the project panel comes back. if self.browser_open { self.browser_open = false; self.panel_open = true; } else { self.panel_open = !self.panel_open; } cx.notify(); } /// The open thread's browser panel while it shows, made on first use. /// The others let go of their streams. fn shown_browser(&mut self, window: &mut Window, cx: &mut Context) -> Option> { let session = self.active.clone().filter(|_| self.browser_open && !self.settings_open); let panel = session.map(|session| { let id = session.entity_id(); if let Some(panel) = self.browsers.get(&id) { return panel.clone(); } let thread_id = session.read(cx).id.clone(); let panel = cx.new(|cx| BrowserPanel::new(&thread_id, window, cx)); let subscription = cx.subscribe(&panel, |this, _, event: &BrowserPanelEvent, cx| match event { BrowserPanelEvent::Close => { this.browser_open = false; cx.notify(); } }); self.observers.entry(id).or_default().push(subscription); self.browsers.insert(id, panel.clone()); panel }); let shown = panel.as_ref().map(|panel| panel.entity_id()); if shown != self.browser_shown { self.browser_shown = shown; for browser in self.browsers.values() { let visible = Some(browser.entity_id()) == shown; browser.update(cx, |browser, cx| browser.set_visible(visible, cx)); } } panel } /// The browser button, lit when the open thread's browser runs while /// the panel is closed, as when the agent opens a page. fn render_browser_toggle(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let live = !self.browser_open && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx)); div() .relative() .child( MetalButton::new("toggle-browser") .ghost() .mini() .icon(IconName::Globe) .tooltip("Browser (Ctrl+Shift+E)") .on_click(cx.listener(|this, _, _, cx| { this.browser_open = !this.browser_open; cx.notify(); })), ) .when(live, |this| { this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p))) }) } fn render_top_bar(&self, panel_visible: bool, cx: &mut Context) -> 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(self.render_browser_toggle(cx)) .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.toggle_project_panel(cx))), ), ) .child(metal::rule(p)) } /// One row of the project panel: an icon, a label and an optional trailer. fn panel_row(icon: impl Into, label: impl Into, 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) -> 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
| 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| { let Some(cwd) = this.active_path(cx) else { return }; this.switch_branch(cwd, branch, false, 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) -> 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) -> 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) -> 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()), previous_worktree: project .sessions .iter() .map(|session| session.read(cx)) .filter(|session| !session.items.is_empty()) .filter_map(|session| Some((session.updated, session.worktree.clone()?))) .filter(|(_, worktree)| worktree.root.exists()) .max_by_key(|(updated, _)| *updated) .map(|(_, worktree)| worktree), }) .collect(), }; cx.set_global(directory); let browser = self.shown_browser(window, cx); let p = metal::palette(cx); let panel_visible = self.panel_open && browser.is_none() && self.active_project().is_some() && window.viewport_size().width >= px(PANEL_MIN_WINDOW); // The browser gives way before the conversation gets too narrow. let chat_min = if browser.is_some() { px(360.) } else { px(0.) }; 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.toggle_project_panel(cx))) .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| { this.browser_open = !this.browser_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(chat_min).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))) .when_some(browser, |this, browser| this.child(browser)), ), ), )) // 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 { 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 { 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 { 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"); } }