//! 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 _, h_flex, input::{InputEvent, InputState}, menu::{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::git::{self, GitInfo}; use crate::models::{Catalog, ModelEntry, Provider, split_key}; use crate::session::{NewSession, Reasoning, Session, from_millis, to_millis}; use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode}; use crate::store::{self, AppState, SavedProject}; use crate::ui::browser::{BrowserPanel, BrowserPanelEvent}; 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, MetalButton, MetalStyled as _, Palette}; use crate::ui::settings::{SettingsEvent, SettingsView}; use crate::worktree::{self, Worktree, WorktreePlan}; use crate::{ CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, SettleThread, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel, ToggleRightPanel, ToggleSidebar, UndoThreadAction, }; mod actions; mod git_actions; mod header; mod palette; mod remote; mod sidebar; use actions::UndoEntry; use palette::CommandPalette; use remote::RemoteState; pub(crate) const SIDEBAR_WIDTH: f32 = 280.; /// The narrowest the thread sidebar drags to, and how much room it leaves /// the conversation. const SIDEBAR_MIN_WIDTH: f32 = 208.; const MAIN_MIN_WIDTH: f32 = 640.; 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); /// How long Ctrl is held before the Ctrl+1–9 hints show. const JUMP_HINT_DELAY: Duration = Duration::from_millis(200); 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, /// The repository it's in (its common directory) and its path inside /// the checkout, for grouping projects in the legacy sidebar. repo: Option<(PathBuf, PathBuf)>, } /// A thread in the sidebar: its project's index and the session. type ThreadEntry = (usize, Entity); /// Names a project across changes to the list: its path, and the remote /// connection it's on (`None` for this device). pub(crate) type ProjectKey = (PathBuf, Option); /// A thread title being edited in place, in the sidebar or the chat header. struct InlineRename { session: Entity, input: Entity, /// In the sidebar row, rather than the chat header. sidebar: bool, _subscription: Subscription, } /// A menu opened from code rather than a right click: the chat header's /// thread menu, the project scope picker's settings, and the like. struct OverlayMenu { menu: Entity, position: Point, _dismiss: Subscription, } /// 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 { /// The open thread's checkout as the git actions see it. git_status: Option<(PathBuf, git::GitStatus)>, /// The git action running, and since when. git_running: Option<(&'static str, Instant)>, /// The row whose hover card stays hidden while its menu is open. hover_card_hidden: Option, 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, /// How many settled threads the Settled shelf lists before "Show N more". settled_shown: usize, /// Projects folded away in the legacy sidebar. collapsed_projects: HashSet, /// Projects whose full thread list the legacy sidebar shows ("Show more"). expanded_projects: HashSet, /// The project the sidebar is filtered to; `None` shows all projects. project_scope: Option, /// The project scope picker, while open, with its search field. scope_picker: Option<(Entity, Subscription)>, /// Threads picked with Ctrl+click or Shift+click, in the order picked. selection: Vec, /// Where a Shift+click range starts. selection_anchor: Option, /// The highlighted row of the sidebar's search results. search_highlight: usize, renaming: Option, overlay_menu: Option, /// The command palette, while open. palette: Option<(Entity, Subscription)>, /// Actions the undo notice can take back, oldest first. undo: Vec, undo_generation: u64, undo_timer: Task<()>, /// When Ctrl went down, for showing the Ctrl+1–9 jump hints after a beat. ctrl_held: Option, jump_hints: bool, /// A title click waiting to see whether it becomes a double-click. title_click: Task<()>, /// The thread sidebar's width, and whether its edge is being dragged. sidebar_width: f32, resizing_sidebar: bool, 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)>, /// The legacy row whose Archive button waits for its Confirm click. archive_armed: Option<(EntityId, Instant)>, /// Where the chat header's title sits, to open its menu under it. title_bounds: Rc>>, /// 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_in(&search, window, |this, _, event: &InputEvent, window, cx| match event { InputEvent::Change => { this.search_highlight = 0; cx.notify(); } InputEvent::PressEnter { .. } => this.open_search_result(window, cx), _ => {} }), ]; 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 { git_status: None, git_running: None, hover_card_hidden: None, 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, settled_shown: sidebar::SETTLED_PAGE, collapsed_projects: HashSet::new(), expanded_projects: HashSet::new(), project_scope: None, scope_picker: None, selection: Vec::new(), selection_anchor: None, search_highlight: 0, renaming: None, overlay_menu: None, palette: None, undo: Vec::new(), undo_generation: 0, undo_timer: Task::ready(()), ctrl_held: None, jump_hints: false, title_click: Task::ready(()), sidebar_width: SIDEBAR_WIDTH, resizing_sidebar: false, editors: Rc::new(editors), editor, hostname: hostname().into(), settings_view, settings_open: false, folder_browser: None, activity: HashMap::new(), settle_armed: None, archive_armed: None, title_bounds: Rc::default(), 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(); self.working_open = state.working_expanded; self.snoozed_open = state.snoozed_expanded; self.settled_open = state.settled_expanded; self.collapsed_projects = state.collapsed_projects.iter().cloned().collect(); self.sidebar_width = state.sidebar_width.unwrap_or(SIDEBAR_WIDTH).max(SIDEBAR_MIN_WIDTH); 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.project_scope = state .project_scope .filter(|path| self.projects.iter().any(|project| project.environment.is_none() && &project.path == path)) .map(|path| (path, None)); 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()), project_scope: self.project_scope.as_ref().filter(|(_, env)| env.is_none()).map(|(path, _)| path.clone()), working_expanded: self.working_open, snoozed_expanded: self.snoozed_open, settled_expanded: self.settled_open, collapsed_projects: { let mut paths: Vec = self.collapsed_projects.iter().cloned().collect(); paths.sort(); paths }, sidebar_width: Some(self.sidebar_width.round()).filter(|width| *width != SIDEBAR_WIDTH), } } /// 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(); } if let Some((_, armed)) = self.archive_armed && armed.elapsed() >= SETTLE_ARMED { self.archive_armed = None; cx.notify(); } // Working threads show how long they've worked. let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working()); if working && self.sidebar_open { 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()); // The open thread's checkout, for the git actions. let status_path = self .active_project() .filter(|project| project.environment.is_none()) .map(|project| worktree.clone().unwrap_or_else(|| project.path.clone())); let task = cx.background_executor().spawn(async move { let infos = paths.into_iter().map(|path| (git::read_info(&path), path)).collect::>(); let status = status_path.and_then(|path| git::status(&path).map(|status| (path, status))); (infos, status) }); cx.spawn(async move |this, cx| { let (infos, status) = task.await; let _ = this.update(cx, |this, cx| { this.git_busy = false; let mut changed = this.git_status != status; this.git_status = status; 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.auto_settle && !state.settled && !state.archived && !state.pinned && state.resume_at.is_none() && now.duration_since(state.updated).unwrap_or_default() >= inactive, ) }; if wake { session.update(cx, |session, cx| session.wake(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.auto_settle && !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 is_open = self.active.as_ref() == Some(&session) && !self.settings_open && window.is_window_active(); if before.working && !now.working && !is_open && !state.unread { session.update(cx, |session, cx| session.set_unread(true, cx)); } let state = session.read(cx); 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); } else if sound { // "Sound only". play_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); let repo = git.as_ref().and_then(|_| git::repository_of(&path)); self.projects.push(Project { repo, 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::Manual => {} 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, cx| { session.subscribe_remote(); session.mark_seen(cx); }); if let Some(chat) = self.chats.get(&session.entity_id()).cloned() { chat.update(cx, |chat, cx| chat.focus_composer(window, cx)); } // Opening a thread drops the multi-select, as in T3 Code. self.selection.clear(); let previous = self.active.replace(session.clone()); if let Some(previous) = previous.filter(|previous| previous != &session) { self.prune_empty_draft(&previous, cx); } if self.renaming.as_ref().is_some_and(|rename| !rename.sidebar) { self.renaming = None; } self.last_git = Instant::now() - GIT_POLL; cx.notify(); } /// Forgets a new thread left without anything sent or typed, as T3 drops /// an empty draft once you move on. fn prune_empty_draft(&mut self, session: &Entity, cx: &mut Context) { if !session.read(cx).is_blank() || session.read(cx).is_remote() { return; } let empty = self .chats .get(&session.entity_id()) .is_none_or(|chat| { let (text, attachments) = chat.read(cx).draft(cx); text.trim().is_empty() && attachments == 0 }); if empty && let Some(ix) = self.project_of(session) { self.remove_thread(ix, session, cx); } } /// The key naming a project. fn project_key(&self, ix: usize) -> ProjectKey { let project = &self.projects[ix]; (project.path.clone(), project.environment.clone()) } fn project_by_key(&self, key: &ProjectKey) -> Option { self.projects.iter().position(|project| project.path == key.0 && project.environment == key.1) } /// Takes a thread out of its project without stopping it, so the undo /// notice can put it back. fn detach_thread(&mut self, project_ix: usize, session: &Entity, cx: &mut Context) { let id = session.entity_id(); self.chats.remove(&id); self.observers.remove(&id); self.activity.remove(&id); if let Some(project) = self.projects.get_mut(project_ix) { project.sessions.retain(|candidate| candidate.entity_id() != id); } cx.notify(); } /// Puts a detached thread back where it was. fn reattach_thread( &mut self, key: &ProjectKey, session: Entity, chat: Entity, window: &mut Window, cx: &mut Context, ) { let Some(project_ix) = self.project_by_key(key) else { session.update(cx, |session, _| session.shutdown()); return; }; 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); self.projects[project_ix].sessions.push(session); cx.notify(); } /// Opens a menu at a point in the window, as a right click would. fn open_menu( &mut self, position: Point, window: &mut Window, cx: &mut Context, build: impl FnOnce(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, ) { // The menus read the workspace as they build, so build once this // update is over. let workspace = cx.weak_entity(); window.defer(cx, move |window, cx| { let menu = PopupMenu::build(window, cx, build); menu.focus_handle(cx).focus(window, cx); let _ = workspace.update(cx, |this, cx| { let dismiss = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| { this.overlay_menu = None; // Hand focus back, so the shortcuts keep working. match this.active.as_ref().and_then(|session| this.chats.get(&session.entity_id())) { Some(chat) if this.renaming.is_none() => chat.update(cx, |chat, cx| chat.focus_composer(window, cx)), _ if this.renaming.is_none() => this.focus_handle.focus(window, cx), _ => {} } cx.notify(); }); this.overlay_menu = Some(OverlayMenu { menu, position, _dismiss: dismiss }); cx.notify(); }); }); } /// Edits a thread's title in place: in its sidebar row, or in the chat /// header. Enter or leaving the field saves, Esc cancels. fn start_rename(&mut self, session: Entity, sidebar: bool, window: &mut Window, cx: &mut Context) { // A row that isn't showing can't host the field; the header can. let sidebar = sidebar && self.sidebar_open && !self.settings_open; if !sidebar && self.active.as_ref() != Some(&session) { if let Some(ix) = self.project_of(&session) { self.select(ix, session.clone(), window, cx); } } 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); input.select_all(window, cx); }); let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event { InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx), _ => {} }); self.renaming = Some(InlineRename { session, input, sidebar, _subscription: subscription }); cx.notify(); } fn commit_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(rename) = self.renaming.take() else { return }; let title = rename.input.read(cx).value().trim().to_string(); if title.is_empty() { window.push_notification(Notification::warning("Thread title cannot be empty"), cx); } else if title != rename.session.read(cx).title.as_ref() { rename.session.update(cx, |session, cx| session.rename(title, cx)); } cx.notify(); } fn cancel_rename(&mut self, cx: &mut Context) { if self.renaming.take().is_some() { cx.notify(); } } /// Opens the previous or next thread in the sidebar (Ctrl+Shift+[ and ]). fn step_thread(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { let rows = self.visible_rows(cx); if rows.is_empty() { return; } let current = self.active.as_ref().and_then(|active| rows.iter().position(|(_, row)| row == active)); let next = match current { Some(ix) => (ix as isize + delta).rem_euclid(rows.len() as isize) as usize, None if delta < 0 => rows.len() - 1, None => 0, }; let (project, session) = rows[next].clone(); self.select(project, session, window, cx); } /// Ctrl+1–9 open the sidebar's first nine threads. fn jump_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let keystroke = &event.keystroke; let modifiers = &keystroke.modifiers; if !modifiers.control || modifiers.shift || modifiers.alt || modifiers.platform || self.palette.is_some() { return; } let Some(digit) = keystroke.key.parse::().ok().filter(|digit| (1..=9).contains(digit)) else { return }; if let Some((project, session)) = self.visible_rows(cx).get(digit - 1).cloned() { cx.stop_propagation(); self.jump_hints = false; self.select(project, session, window, cx); } } /// Shows the jump hints once Ctrl has been held for a beat. fn modifiers_changed(&mut self, event: &ModifiersChangedEvent, window: &mut Window, cx: &mut Context) { let held = event.modifiers.control && !event.modifiers.shift && !event.modifiers.alt; if !held { self.ctrl_held = None; if self.jump_hints { self.jump_hints = false; cx.notify(); } return; } if self.ctrl_held.is_some() { return; } let since = Instant::now(); self.ctrl_held = Some(since); cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(JUMP_HINT_DELAY).await; let _ = this.update(cx, |this, cx| { if this.ctrl_held == Some(since) { this.jump_hints = true; cx.notify(); } }); }) .detach(); } /// Opens a settings page by its name in the settings view (`Usage`). fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context) { self.open_settings(window, cx); self.settings_view.update(cx, |view, cx| view.open_page_named(page, cx)); } /// The settle button on a thread card: settles at once, or arms itself /// for a confirming second click when Settle confirmation is on. fn press_settle(&mut self, session: Entity, window: &mut Window, 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_threads(vec![session], window, cx); } } 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()) }; // Like T3 Code, a worktree goes too only when no other thread uses it, // and only once the user says so. let entity_id = session.entity_id(); let worktree = worktree.filter(|worktree| { worktree.root.exists() && !self.projects.iter().flat_map(|project| &project.sessions).any(|other| { other.entity_id() != entity_id && other.read(cx).worktree.as_ref().is_some_and(|other| other.root == worktree.root) }) }); 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)); }) .detach(); if let Some(worktree) = worktree { let name = worktree.root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default(); let message = format!("This thread is the only one linked to this worktree:\n{name}\n\nDelete the worktree too?"); // After the dialog that asked to delete the thread has closed. window.defer(cx, move |window, cx| actions::confirm(&message, true, window, cx, move |window, cx| { let worktree = worktree.clone(); let name = name.clone(); let task = cx.background_executor().spawn(async move { worktree::remove_force(&worktree) }); window .spawn(cx, async move |cx| { if let Err(err) = task.await { let _ = cx.update(|window, cx| { window.push_notification( Notification::error(format!("Could not remove {name}. {err}")) .title("Failed to delete worktree"), cx, ); }); } }) .detach(); })); } } 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(); } // ---- 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 archived thread, most recently active first, for Settings → /// Archive. (Settled threads stay in the sidebar's Settled shelf.) 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).archived) .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.set_archived(false, 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); } } 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 } /// 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 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) .when(has_thread, |this| this.child(self.render_git_actions(cx))) .child( Self::panel_row(Lucide::FileDiff, "Changes", p) .child( div() .font_family(metal::MONO) .text_size(px(11.5)) .text_color(metal::diff_ink(p).added) .child(format!("+{}", git.added)), ) .child( div() .font_family(metal::MONO) .text_size(px(11.5)) .text_color(metal::diff_ink(p).removed) .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_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| { // T3's chat.new: the project picker once there's a choice. if this.legacy_sidebar() { this.new_thread_button(true, window, cx); } else { this.new_thread_button(false, window, cx); } })) .on_action(cx.listener(|this, _: &NewThreadLocal, window, cx| this.new_thread_button(true, window, cx))) .on_action(cx.listener(|this, _: &NewThreadWithoutProject, window, cx| { this.new_thread_without_project(window, cx) })) .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| this.toggle_palette(window, cx))) .on_action(cx.listener(|this, _: &PreviousThread, window, cx| this.step_thread(-1, window, cx))) .on_action(cx.listener(|this, _: &NextThread, window, cx| this.step_thread(1, window, cx))) .on_action(cx.listener(|this, _: &SettleThread, window, cx| { if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) { this.toggle_settled(session, window, cx); } })) .on_action(cx.listener(|this, _: &PinThread, window, cx| { if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) { this.toggle_pin(session, window, cx); } })) .on_action(cx.listener(|this, _: &UndoThreadAction, window, cx| { if !this.undo_latest(window, cx) { cx.propagate(); } })) .on_action(cx.listener(|this, _: &CopyThreadReference, window, cx| { if let Some(session) = this.active.clone().filter(|session| !session.read(cx).is_blank()) { Self::copy_thread_reference(&session, window, cx); } })) .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx))) .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| { this.browser_open = !this.browser_open; cx.notify(); })) .on_action(cx.listener(|this, _: &SelectTheme, window, cx| { this.open_palette(palette::PaletteView::Themes, window, cx) })) .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx))) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx))) // Dragging the sidebar's edge. .when(self.resizing_sidebar, |this| { this.cursor_col_resize() .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| { let max = (window.viewport_size().width.as_f32() - MAIN_MIN_WIDTH).max(SIDEBAR_MIN_WIDTH); this.sidebar_width = event.position.x.as_f32().clamp(SIDEBAR_MIN_WIDTH, max); cx.notify(); })) .on_mouse_up( MouseButton::Left, cx.listener(|this, _, _, cx| { this.resizing_sidebar = false; cx.notify(); }), ) }) .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| { this.modifiers_changed(event, window, cx) })) .on_action(cx.listener(|this, _: &OpenInEditor, window, cx| this.open_in_editor(this.editor, 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(window, 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 command palette floats over everything; a click beside it closes it. .when_some(self.palette.as_ref().map(|(palette, _)| palette.clone()), |this, palette| { this.child( div() .absolute() .inset_0() .flex() .flex_col() .items_center() .pt(window.viewport_size().height * 0.12) .px(px(16.)) .bg(black().opacity(if p.dark { 0.4 } else { 0.16 })) .occlude() .on_mouse_down( MouseButton::Left, cx.listener(|this, _, window, cx| this.close_palette(window, cx)), ) .child(div().on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()).child(palette)), ) }) .when_some(self.overlay_menu.as_ref(), |this, overlay| { this.child( deferred( anchored() .position(overlay.position) .snap_to_window_with_margin(px(8.)) .child(overlay.menu.clone()), ) .with_priority(2), ) }) // 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"); } }