Vectrum
Sign In
1511 lines · 59.5 KB · Rust
//! 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<ProjectSummary>,
}

pub struct ProjectSummary {
    pub name: SharedString,
    pub path: PathBuf,
    /// The folder threads "without a project" run in.
    pub scratch: bool,
    pub branch: Option<String>,
    pub branches: Vec<String>,
}

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<String>,
    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<Entity<Session>>,
    /// `None` until read, or when the folder isn't a git repository.
    git: Option<GitInfo>,
}

/// A thread in the sidebar: its project's index and the session.
type ThreadEntry = (usize, Entity<Session>);

/// A settled thread, as the settings' archive lists it.
pub struct SettledThread {
    pub session: Entity<Session>,
    pub project: SharedString,
    pub initials: SharedString,
    pub tint: usize,
    pub title: SharedString,
    pub provider: Provider,
    pub age: String,
}

/// A GUI program that can open a folder.
struct EditorApp {
    name: &'static str,
    program: PathBuf,
}

pub struct Workspace {
    projects: Vec<Project>,
    chats: HashMap<EntityId, Entity<ChatView>>,
    active: Option<Entity<Session>>,
    active_project: usize,
    search: Entity<InputState>,
    sidebar_open: bool,
    panel_open: bool,
    settled_open: bool,
    editors: Rc<Vec<EditorApp>>,
    editor: usize,
    hostname: SharedString,
    settings_view: Entity<SettingsView>,
    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<EntityId, (bool, bool)>,
    focus_handle: FocusHandle,
    observers: HashMap<EntityId, Vec<Subscription>>,
    _subscriptions: Vec<Subscription>,
    _git_poll: Task<()>,
}

impl Workspace {
    pub fn new(initial_project: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
        let workspace = cx.weak_entity();
        let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx));
        let subscriptions = vec![
            cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event {
                SettingsEvent::Close => this.close_settings(window, cx),
            }),
            cx.on_app_quit(|this: &mut Self, cx| {
                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::<Vec<_>>()
                }) else {
                    break;
                };
                let infos = cx
                    .background_executor()
                    .spawn(async move {
                        paths.into_iter().map(|path| {
                            let info = git::read_info(&path);
                            (path, info)
                        })
                        .collect::<Vec<_>>()
                    })
                    .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<Self>) {
        let ix = self.ensure_project(path, None);
        let settings = self.current_settings(cx);
        self.replace_thread(ix, settings, window, cx);
    }

    /// The settings of the open thread, which a new thread inherits.
    fn current_settings(&self, cx: &App) -> ThreadSettings {
        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<Self>) {
        let settings = self.new_thread_settings(cx);
        self.new_thread_with(project_ix, settings, window, cx);
    }

    fn new_thread_with(
        &mut self,
        project_ix: usize,
        settings: ThreadSettings,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Option<Entity<ChatView>> {
        let project = self.projects.get(project_ix)?;
        let 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<Self>,
    ) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>) -> Option<usize> {
        self.projects
            .iter()
            .position(|project| project.sessions.iter().any(|candidate| candidate == session))
    }

    fn on_chat_event(
        &mut self,
        chat: &Entity<ChatView>,
        event: &ChatEvent,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        let session = chat.read(cx).session().clone();
        let Some(project_ix) = self.project_of(&session) else { return };
        match event {
            ChatEvent::NewThreadWithModel(model) => {
                let settings = self.settings_for(model, cx);
                self.replace_thread(project_ix, settings, window, cx);
            }
            ChatEvent::SwitchProject(path) => {
                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<Session>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        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<Session>, cx: &mut Context<Self>) {
        session.update(cx, |session, cx| session.settle(cx));
        cx.notify();
    }

    fn delete_thread(
        &mut self,
        project_ix: usize,
        session: Entity<Session>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        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<Session>, cx: &mut Context<Self>) {
        session.update(cx, |session, _| session.shutdown());
        let id = session.entity_id();
        self.chats.remove(&id);
        self.observers.remove(&id);
        self.activity.remove(&id);
        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<Self>) {
        let paths = cx.prompt_for_paths(PathPromptOptions {
            files: false,
            directories: true,
            multiple: false,
            prompt: Some("Open project".into()),
        });
        cx.spawn_in(window, async move |this, cx| {
            let Ok(Ok(Some(paths))) = paths.await else { return };
            let Some(path) = paths.into_iter().next() else { return };
            let _ = this.update_in(cx, |this, window, cx| this.add_project(path, window, cx));
        })
        .detach();
    }

    /// The threads matching the search, split into open and settled, most
    /// recently active first.
    fn threads(&self, cx: &App) -> (Vec<ThreadEntry>, Vec<ThreadEntry>) {
        let query = self.search.read(cx).value().trim().to_lowercase();
        let mut threads: Vec<_> = self
            .projects
            .iter()
            .enumerate()
            .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
            .filter(|(ix, session)| {
                query.is_empty()
                    || session.read(cx).title.to_lowercase().contains(&query)
                    || self.projects[*ix].name.to_lowercase().contains(&query)
            })
            .collect();
        threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
        threads.into_iter().partition(|(_, session)| !session.read(cx).settled)
    }

    fn active_project(&self) -> Option<&Project> {
        self.projects.get(self.active_project)
    }

    fn open_in_editor(&mut self, editor: usize, window: &mut Window, cx: &mut Context<Self>) {
        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<Self>) {
        let Some(path) = self.active_project().map(|project| project.path.clone()) else { return };
        let task = cx.background_executor().spawn(async move {
            let output = git::git(&path, &["switch", &branch]);
            let info = git::read_info(&path);
            (path, output, info)
        });
        cx.spawn_in(window, async move |this, cx| {
            let (path, output, info) = task.await;
            let _ = this.update_in(cx, |this, window, cx| {
                if let Err(err) = output {
                    window.push_notification(Notification::error(format!("git switch failed: {err}")), cx);
                }
                if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) {
                    project.git = info;
                }
                cx.notify();
            });
        })
        .detach();
    }

    /// Hands a git chore to Claude in the open thread.
    fn ask_claude(&mut self, prompt: &str, cx: &mut Context<Self>) {
        if let Some(session) = &self.active {
            session.update(cx, |session, cx| session.send_message(prompt.to_string(), Vec::new(), cx));
        }
    }

    // ---- what the settings view reads and changes ----------------------

    fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.settings_open = true;
        self.settings_view.update(cx, |view, cx| view.opened(window, cx));
        cx.notify();
    }

    fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.settings_open = false;
        if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
            chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
        }
        cx.notify();
    }

    /// Lets every open conversation pick up changed settings.
    pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        for chat in self.chats.values() {
            chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
        }
        cx.notify();
    }

    pub fn editor_names(&self) -> Vec<&'static str> {
        self.editors.iter().map(|app| app.name).collect()
    }

    pub fn editor(&self) -> usize {
        self.editor
    }

    pub fn set_editor(&mut self, editor: usize, cx: &mut Context<Self>) {
        let Some(app) = self.editors.get(editor) else { return };
        self.editor = editor;
        let name = app.name.to_string();
        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<SettledThread> {
        let mut threads: Vec<_> = self
            .projects
            .iter()
            .flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
            .filter(|(_, session)| session.read(cx).settled)
            .collect();
        threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
        threads
            .into_iter()
            .map(|(project, session)| {
                let state = session.read(cx);
                SettledThread {
                    session: session.clone(),
                    project: project.name.clone(),
                    initials: project.initials.clone(),
                    tint: project.tint,
                    title: state.title.clone(),
                    provider: state.provider,
                    age: relative_time(state.updated),
                }
            })
            .collect()
    }

    /// Brings a settled thread back and opens it.
    pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        let Some(ix) = self.project_of(&session) else { return };
        session.update(cx, |session, cx| session.unsettle(cx));
        self.select(ix, session, window, cx);
        self.close_settings(window, cx);
    }

    pub fn forget_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        if let Some(ix) = self.project_of(&session) {
            self.delete_thread(ix, session, window, cx);
        }
    }

    fn render_sidebar_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        h_flex()
            .flex_none()
            .h(px(52.))
            .pl(px(10.))
            .pr(px(12.))
            .gap(px(8.))
            .child(
                MetalButton::new("hide-sidebar")
                    .ghost()
                    .mini()
                    .icon(Lucide::PanelLeftClose)
                    .tooltip("Hide sidebar (Ctrl+B)")
                    .on_click(cx.listener(|this, _, _, cx| {
                        this.sidebar_open = false;
                        cx.notify();
                    })),
            )
            .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
            .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
            .child(metal::tag("Code", p))
    }

    fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let projects: Vec<(usize, SharedString)> =
            self.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<Session>,
        cx: &mut Context<Self>,
    ) -> AnyElement {
        let p = metal::palette(cx);
        let project = &self.projects[project_ix];
        let state = session.read(cx);
        let id = session.entity_id();
        let selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
        let (led, pulse) = if state.settled {
            (Led::Off, false)
        } else if state.needs_attention() {
            (Led::Waiting, true)
        } else {
            match state.status {
                SessionStatus::Working => (Led::Live, true),
                SessionStatus::Idle => (Led::Live, false),
                SessionStatus::Starting => (Led::Off, false),
                SessionStatus::Exited => (Led::Failed, false),
            }
        };
        let branch = 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<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let (open, settled) = self.threads(cx);
        let searching = !self.search.read(cx).value().trim().is_empty();
        let 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<Self>) -> 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<Self>) -> impl IntoElement {
        v_flex()
            .w(px(SIDEBAR_WIDTH))
            .h_full()
            .flex_none()
            .child(self.render_sidebar_header(cx))
            .child(self.render_sidebar_toolbar(cx))
            .child(self.render_thread_list(cx))
            .child(self.render_sidebar_footer(cx))
    }

    fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let project = self.active_project();
        let title = self.active.as_ref().map(|session| session.read(cx).title.clone());

        v_flex()
            .flex_none()
            .child(
                h_flex()
                    .h(px(52.))
                    .pl(px(if self.sidebar_open { 20. } else { 12. }))
                    .pr(px(12.))
                    .gap(px(10.))
                    .when(!self.sidebar_open, |this| {
                        this.child(
                            MetalButton::new("show-sidebar")
                                .ghost()
                                .mini()
                                .icon(Lucide::PanelLeftOpen)
                                .tooltip("Show sidebar (Ctrl+B)")
                                .on_click(cx.listener(|this, _, _, cx| {
                                    this.sidebar_open = true;
                                    cx.notify();
                                })),
                        )
                    })
                    .when_some(project, |this, project| {
                        this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
                            div()
                                .flex_none()
                                .text_size(px(14.))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink2)
                                .child(project.name.clone()),
                        )
                    })
                    .when_some(title, |this, title| {
                        this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
                            div()
                                .flex_1()
                                .min_w_0()
                                .truncate()
                                .text_size(px(14.))
                                .font_weight(FontWeight::SEMIBOLD)
                                .text_color(p.ink)
                                .child(title),
                        )
                    })
                    .child(div().flex_1())
                    .child(
                        MetalButton::new("toggle-panel")
                            .ghost()
                            .mini()
                            .icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
                            .tooltip("Project panel (Ctrl+Shift+B)")
                            .on_click(cx.listener(|this, _, _, cx| {
                                this.panel_open = !this.panel_open;
                                cx.notify();
                            })),
                    ),
            )
            .child(metal::rule(p))
    }

    /// One row of the project panel: an icon, a label and an optional trailer.
    fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
        h_flex()
            .h(px(36.))
            .px(px(10.))
            .gap(px(10.))
            .rounded(px(12.))
            .text_size(px(13.5))
            .font_weight(FontWeight::MEDIUM)
            .text_color(p.ink)
            .child(icon.into().size(px(15.)).text_color(p.ink2))
            .child(div().flex_1().min_w_0().truncate().child(label.into()))
    }

    fn render_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let Some(project) = self.active_project() else {
            return div().into_any_element();
        };
        let 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<Div>| row.cursor_pointer().hover(|style| style.bg(p.row_hover));

        let editor_row = match editor {
            Some(app) => h_flex()
                .gap(px(2.))
                .child(
                    hover(
                        Self::panel_row(Lucide::Code, format!("Open in {}", app.name), p)
                            .flex_1()
                            .id("open-editor"),
                    )
                    .on_click(cx.listener(|this, _, window, cx| this.open_in_editor(this.editor, window, cx))),
                )
                .child(
                    MetalButton::new("editor-menu")
                        .ghost()
                        .mini()
                        .icon(IconName::ChevronDown)
                        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                            for (ix, app) in editors.iter().enumerate() {
                                let workspace = workspace.clone();
                                menu = menu.item(PopupMenuItem::new(app.name).on_click(move |_, window, cx| {
                                    let _ = workspace.update(cx, |this, cx| this.open_in_editor(ix, window, cx));
                                }));
                            }
                            menu
                        }),
                )
                .into_any_element(),
            None => Self::panel_row(Lucide::Code, "No editor found", p)
                .text_color(p.ink3)
                .into_any_element(),
        };

        let git_rows: AnyElement = match git {
            Some(git) => {
                let branches = git.branches.clone();
                let current = git.branch.clone();
                let workspace = cx.entity().downgrade();
                let branch_label = git.branch.clone().unwrap_or_else(|| "Detached HEAD".into());
                let has_changes = git.added + git.removed > 0;
                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<Self>) -> impl IntoElement {
        const COMMIT: &str = "Commit all current changes in this repository with a concise, descriptive commit message.";
        const COMMIT_AND_PUSH: &str = "Commit all current changes in this repository with a concise, descriptive commit message, then push the branch to its remote.";
        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<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        v_flex()
            .size_full()
            .items_center()
            .justify_center()
            .gap(px(14.))
            .child(metal::engraved("No thread open", p))
            .child(
                div()
                    .text_size(px(22.))
                    .font_weight(FontWeight::SEMIBOLD)
                    .text_color(p.ink)
                    .child("Pick a thread, or start a new one."),
            )
            .child(
                MetalButton::new("empty-new-thread")
                    .primary()
                    .icon(Lucide::SquarePen)
                    .label("New thread")
                    .on_click(cx.listener(|this, _, window, cx| {
                        if this.projects.is_empty() {
                            this.prompt_for_project(window, cx);
                        } else {
                            this.new_thread(this.active_project, window, cx);
                        }
                    })),
            )
    }
}

impl Render for Workspace {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let chat = self
            .active
            .as_ref()
            .and_then(|session| self.chats.get(&session.entity_id()))
            .cloned();
        let title = match &self.active {
            Some(session) => format!("{} — Vectrum Code", session.read(cx).title),
            None => "Vectrum Code".to_string(),
        };
        window.set_window_title(&title);
        // The conversation's start screen reads the projects from here.
        let scratch = scratch_dir();
        let directory = ProjectDirectory {
            hostname: self.hostname.clone(),
            projects: self
                .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<PathBuf> {
    let paths = std::env::var_os("PATH")?;
    std::env::split_paths(&paths)
        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
        .find(|path| path.is_file())
}

fn detect_editors() -> Vec<EditorApp> {
    const CANDIDATES: &[(&str, &[&str])] = &[
        ("Zed", &["zeditor", "zed"]),
        ("VS Code", &["code"]),
        ("Cursor", &["cursor"]),
        ("VSCodium", &["codium"]),
        ("Sublime Text", &["subl"]),
        ("Kate", &["kate"]),
        ("File manager", &["xdg-open"]),
    ];
    CANDIDATES
        .iter()
        .filter_map(|(name, programs)| Some(EditorApp { name, program: find_program(programs)? }))
        .collect()
}

/// 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<PathBuf> {
    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");
    }
}