//! 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. use std::{ collections::HashMap, hash::{Hash as _, Hasher as _}, path::PathBuf, process::{Command, Stdio}, rc::Rc, time::{Duration, Instant}, }; use gpui_kit::component::{ ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex, input::{Input, InputEvent, InputState}, menu::{DropdownMenu as _, PopupMenuItem}, notification::Notification, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::claude::PermissionMode; use crate::git::{self, GitInfo}; use crate::models::{Catalog, ModelEntry, Provider}; use crate::session::{Reasoning, Session, SessionStatus}; use crate::ui::chat::{ChatEvent, ChatView, display_path}; use crate::ui::pickers; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::settings::{SettingsEvent, SettingsView}; use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar}; 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 GIT_POLL: Duration = Duration::from_secs(4); /// 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, pub projects: Vec, } pub struct ProjectSummary { pub name: SharedString, pub path: PathBuf, /// The folder threads "without a project" run in. pub scratch: bool, pub branch: Option, pub branches: Vec, } impl Global for ProjectDirectory {} /// What a new thread starts with: the provider, model and settings the user /// picked last. #[derive(Clone, Default)] struct ThreadSettings { provider: Provider, model: Option, mode: crate::claude::PermissionMode, reasoning: Reasoning, fast_mode: bool, } struct Project { path: PathBuf, name: SharedString, initials: SharedString, /// Index into the palette's tints. tint: usize, sessions: Vec>, /// `None` until read, or when the folder isn't a git repository. git: 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, } pub struct Workspace { projects: Vec, chats: HashMap>, active: Option>, active_project: usize, search: Entity, sidebar_open: bool, panel_open: bool, settled_open: bool, editors: Rc>, editor: usize, hostname: SharedString, settings_view: Entity, settings_open: bool, /// Whether each thread was working and waiting for the user when last /// seen, to notice when it finishes or starts waiting. activity: HashMap, focus_handle: FocusHandle, observers: HashMap>, _subscriptions: Vec, _git_poll: Task<()>, } impl Workspace { pub fn new(initial_project: PathBuf, 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| { for project in &this.projects { for session in &project.sessions { session.update(cx, |session, _| session.shutdown()); } } async {} }), // The Auto colorway follows the desktop's light/dark setting. cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)), cx.subscribe(&search, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }), ]; let git_poll = cx.spawn(async move |this, cx| { let mut polls: u32 = 0; loop { polls = polls.wrapping_add(1); let Ok(paths) = this.update(cx, |this, _| { this.projects.iter().map(|project| project.path.clone()).collect::>() }) else { break; }; let infos = cx .background_executor() .spawn(async move { paths.into_iter().map(|path| { let info = git::read_info(&path); (path, info) }) .collect::>() }) .await; let applied = this.update(cx, |this, cx| { let mut changed = false; for (path, info) in infos { if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) && project.git != info { project.git = info; changed = true; } } // Also repaint about once a minute so thread ages stay current. if changed || polls.is_multiple_of(15) { cx.notify(); } }); if applied.is_err() { break; } cx.background_executor().timer(GIT_POLL).await; } }); let editors = detect_editors(); let saved_editor = crate::settings::get(|settings| settings.editor.clone()); let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0); let mut workspace = Self { projects: Vec::new(), chats: HashMap::new(), active: None, active_project: 0, search, sidebar_open: true, panel_open: true, settled_open: false, editors: Rc::new(editors), editor, hostname: hostname().into(), settings_view, settings_open: false, activity: HashMap::new(), focus_handle: cx.focus_handle(), observers: HashMap::new(), _subscriptions: subscriptions, _git_poll: git_poll, }; let ix = workspace.ensure_project(initial_project, None); let settings = workspace.new_thread_settings(cx); workspace.replace_thread(ix, settings, window, cx); workspace } 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 { self.active .as_ref() .map(|active| { let active = active.read(cx); ThreadSettings { provider: active.provider, model: active.model.clone(), mode: active.permission_mode, reasoning: active.reasoning, fast_mode: active.fast_mode, } }) .unwrap_or_default() } /// 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) = crate::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 a Claude session started. None => { if let Some((provider, id)) = key.split_once(':') && let Some(provider) = Provider::from_key(provider) { settings.provider = provider; 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.provider, 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 } 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 cwd = project.path.clone(); let session = cx.new(|cx| { Session::new( settings.provider, cwd, settings.model, settings.mode, settings.reasoning, settings.fast_mode, cx, ) }); 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.clone()); self.select(project_ix, session, window, cx); Some(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).items.is_empty()); 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, and posts a desktop notification when a thread finishes or /// starts waiting for the user while the window is in the background. fn on_session_changed(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { cx.notify(); if !crate::settings::get(|settings| settings.notify) { self.activity.clear(); return; } let state = session.read(cx); let now = (state.is_working(), state.needs_attention()); let before = self.activity.insert(session.entity_id(), now).unwrap_or_default(); if window.is_window_active() || state.settled { return; } if now.1 && !before.1 { desktop_notification(&state.title, "Waiting for you"); } else if before.0 && !now.0 { desktop_notification(&state.title, "Finished"); } } 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) => { if let Some(target) = self.projects.iter().position(|project| &project.path == path) { 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) => self.switch_branch(branch.clone(), window, 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 { provider: model.provider, model: Some(model.id.clone()), mode: current.mode, reasoning, fast_mode: current.fast_mode && model.fast_mode, } } /// The index of the 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.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(), sessions: Vec::new(), git, }); self.projects.len() - 1 } fn select( &mut self, project_ix: usize, session: Entity, window: &mut Window, cx: &mut Context, ) { self.active_project = project_ix; 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); cx.notify(); } fn settle_thread(&mut self, session: Entity, cx: &mut Context) { session.update(cx, |session, cx| session.settle(cx)); cx.notify(); } fn delete_thread( &mut self, project_ix: usize, session: Entity, window: &mut Window, cx: &mut Context, ) { self.remove_thread(project_ix, &session, cx); let id = session.entity_id(); if self.active.as_ref().is_some_and(|active| active.entity_id() == 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.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(); } fn prompt_for_project(&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(); } /// The threads matching the search, split into open and settled, most /// recently active first. 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) } fn open_in_editor(&mut self, editor: usize, window: &mut Window, cx: &mut Context) { let (Some(app), Some(project)) = (self.editors.get(editor), self.active_project()) else { return; }; let result = Command::new(&app.program) .arg(&project.path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn(); match result { Ok(mut child) => { // Reap it so it doesn't linger as a zombie. std::thread::spawn(move || child.wait()); self.set_editor(editor, cx); } Err(err) => window.push_notification( Notification::error(format!("Couldn't start {}: {err}", app.name)), cx, ), } cx.notify(); } fn switch_branch(&mut self, branch: String, window: &mut Window, cx: &mut Context) { let Some(path) = self.active_project().map(|project| project.path.clone()) else { return }; let task = cx.background_executor().spawn(async move { let output = git::git(&path, &["switch", &branch]); let info = git::read_info(&path); (path, output, info) }); cx.spawn_in(window, async move |this, cx| { let (path, output, info) = task.await; let _ = this.update_in(cx, |this, window, cx| { if let Err(err) = output { window.push_notification(Notification::error(format!("git switch failed: {err}")), cx); } if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) { project.git = info; } cx.notify(); }); }) .detach(); } /// Hands a git chore to Claude in the open thread. fn ask_claude(&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) { 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(); crate::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); } } 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.projects.iter().enumerate().map(|(ix, project)| (ix, project.name.clone())).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); } })), ) } 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 { 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 = project.git.as_ref().and_then(|git| git.branch.clone()); let settled = state.settled; let group = SharedString::from(format!("thread-{id}")); let select_session = session.clone(); let action_session = session.clone(); let compact = crate::settings::get(|settings| settings.compact_threads); let action = div().invisible().group_hover(group.clone(), |this| this.visible()).child(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.delete_thread(project_ix, action_session.clone(), window, cx) })) } else { MetalButton::new(("settle-thread", id.as_u64() as usize)) .ghost() .mini() .icon(Lucide::Archive) .tooltip("Settle: stop Claude and file the thread away") .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.settle_thread(action_session.clone(), cx) })) }); let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p); let title = div() .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(relative_time(state.updated)); 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) })); if compact { return card .child( h_flex() .h(px(22.)) .gap(px(7.)) .child(metal::badge(project.initials.clone(), project.tint, p)) .child(title.flex_1().min_w_0().text_size(px(13.)).font_weight(FontWeight::MEDIUM)) .child(action) .child(age) .child(led), ) .into_any_element(); } 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()), ) .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(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(action) .child(led), ) .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 settled_open = self.settled_open || searching; let settled_count = settled.len(); let open_cards: Vec<_> = open.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)).collect(); let settled_cards: Vec<_> = if settled_open { settled.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)).collect() } else { Vec::new() }; 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(open.is_empty() && settled.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(open_cards) .when(settled_count > 0, |this| { this.child( h_flex() .id("settled-toggle") .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(|this, _, _, cx| { this.settled_open = !this.settled_open; cx.notify(); })) .child(format!("Settled ({settled_count})")) .child(div().flex_1().h(px(1.)).bg(p.rule)) .child( Icon::new(if settled_open { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(12.)), ), ) .when(settled_open, |this| { this.children(settled_cards) }) }) } fn render_sidebar_footer(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let account = crate::models::Catalog::global(cx).claude_account.clone(); 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(|| "Connecting…".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 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( MetalButton::new("toggle-panel") .ghost() .mini() .icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen }) .tooltip("Project panel (Ctrl+Shift+B)") .on_click(cx.listener(|this, _, _, cx| { this.panel_open = !this.panel_open; cx.notify(); })), ), ) .child(metal::rule(p)) } /// One row of the project panel: an icon, a label and an optional trailer. fn panel_row(icon: impl Into, 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 git = project.git.clone(); let has_thread = self.active.is_some(); let cost = self .active .as_ref() .map(|session| session.read(cx).total_cost) .filter(|cost| *cost > 0. && crate::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; v_flex() .gap(px(2.)) .child( MetalButton::new("branch-menu") .ghost() .w_full() .h(px(36.)) .px(px(10.)) .rounded(px(12.)) .justify_start() .gap(px(10.)) .text_size(px(13.5)) .text_color(p.ink) .icon(Lucide::GitBranch) .label(branch_label) .stretch() .caret() .dropdown_menu(move |mut menu, _, _| { menu = menu.min_w(px(220.)).label("Switch branch"); for branch in branches.clone() { let workspace = workspace.clone(); let checked = current.as_deref() == Some(branch.as_str()); menu = menu.item(PopupMenuItem::new(branch.clone()).checked(checked).on_click( move |_, window, cx| { let branch = branch.clone(); let _ = workspace .update(cx, |this, cx| this.switch_branch(branch, window, cx)); }, )); } menu }), ) .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)), ), ) .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( 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(&project.path)), ) .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."; 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_claude(COMMIT, cx))) } else { this.opacity(0.45) } }) .tooltip(|window, cx| { gpui_kit::component::tooltip::Tooltip::new("Ask Claude 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 commit = workspace.clone(); let push = workspace.clone(); menu.min_w(px(220.)) .item(PopupMenuItem::new("Ask Claude to commit").on_click(move |_, _, cx| { let _ = commit.update(cx, |this, cx| this.ask_claude(COMMIT, cx)); })) .item(PopupMenuItem::new("Ask Claude to commit and push").on_click(move |_, _, cx| { let _ = push.update(cx, |this, cx| this.ask_claude(COMMIT_AND_PUSH, cx)); })) }), ) } 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 .projects .iter() .map(|project| ProjectSummary { name: project.name.clone(), path: project.path.clone(), scratch: Some(&project.path) == scratch.as_ref(), branch: project.git.as_ref().and_then(|git| git.branch.clone()), branches: project.git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(), }) .collect(), }; cx.set_global(directory); let p = metal::palette(cx); let panel_visible = self.panel_open && self.active_project().is_some() && window.viewport_size().width >= px(PANEL_MIN_WINDOW); h_flex() .key_context("Workspace") .track_focus(&self.focus_handle) .size_full() .bg(p.page) .text_color(p.ink) .font_family(cx.theme().font_family.clone()) .on_action(cx.listener(|this, _: &NewThread, window, cx| { if this.projects.is_empty() { this.prompt_for_project(window, cx); } else { this.new_thread(this.active_project, window, cx); } })) .on_action(cx.listener(|this, _: &OpenProject, window, cx| { this.prompt_for_project(window, cx) })) .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { this.sidebar_open = !this.sidebar_open; cx.notify(); })) .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| { this.panel_open = !this.panel_open; cx.notify(); })) .on_action(cx.listener(|this, _: &FocusSearch, window, cx| { this.settings_open = false; this.sidebar_open = true; this.search.update(cx, |search, cx| search.focus(window, cx)); cx.notify(); })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { if this.settings_open { this.close_settings(window, cx); } else { this.open_settings(window, cx); } })) .when(self.settings_open, |this| this.child(self.settings_view.clone())) .when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx))) .when(!self.settings_open, |this| this.child( div() .flex_1() .min_w_0() .h_full() .py(px(SHEET_INSET)) .pr(px(SHEET_INSET)) .when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET))) .child( v_flex() .size_full() .rounded(px(SHEET_RADIUS)) .plate(p) .child(self.render_top_bar(panel_visible, cx)) .child( h_flex() .flex_1() .min_h_0() .items_start() .child(div().flex_1().min_w_0().h_full().map(|this| match chat { Some(chat) => this.child(chat), None => this.child(self.render_empty_sheet(cx)), })) .when(panel_visible, |this| this.child(self.render_panel(cx))), ), ), )) } } /// 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(instant: Instant) -> String { let secs = instant.elapsed().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()) } 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() } /// Posts a desktop notification through `notify-send`, if it's installed. fn desktop_notification(title: &str, body: &str) { let Some(program) = find_program(&["notify-send"]) else { return }; let spawned = Command::new(program) .args(["--app-name=Vectrum Code", title, body]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn(); if let Ok(mut child) = spawned { std::thread::spawn(move || child.wait()); } } /// Where threads started without a project run. pub(crate) fn scratch_dir() -> Option { let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?; let dir = base.join("vectrum-code").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"); } }