use ely_gpui_component::{ buttons::{ButtonVariant, IconButton, SegmentedControl}, lists::List, primitives::{FocusScope, IconName}, shell::{ColumnShell, ResizeBorder, ShellColumn}, theme::{ActiveTheme, ControlSize}, typography::Caption, }; use gpui::{ Action, App, AppContext, Context, Entity, EntityId, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Window, actions, div, prelude::FluentBuilder, }; use crate::{ blur::Blur, files::Files, git::panel::{Acted, GitPanel}, menus, sessions::{self, Session}, settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings}, term::{ Events, Launch, Pty, TerminalEvent, TerminalView, config, shell::{ShellIntegration, launch}, spawn, }, }; actions!( elyterminal, [ ToggleSessions, TogglePanel, NewSession, CloseSession, NextSession, PreviousSession, LastSession ] ); /// Shows the session at this place, from 0; past the last one, the key goes on to the program. #[derive(Clone, Debug, PartialEq, Action)] #[action(namespace = elyterminal, no_json)] pub struct SelectSession(pub usize); /// The key context of the main window's content. pub const CONTEXT: &str = "Workspace"; /// The window's title while the program names none. pub const TITLE: &str = "ELYTerminal"; /// Ghostty's keys: Cmd on macOS; off it, Ctrl-Shift for keys a terminal takes, Alt for digits. pub fn bind_keys(cx: &mut App) { let (sessions, panel, new, close, digit) = if cfg!(target_os = "macos") { ("cmd-b", "cmd-alt-b", "cmd-t", "cmd-w", "cmd") } else { ( "ctrl-shift-b", "ctrl-shift-alt-b", "ctrl-shift-t", "ctrl-shift-w", "alt", ) }; let context = Some(CONTEXT); cx.bind_keys([ KeyBinding::new(sessions, ToggleSessions, context), KeyBinding::new(panel, TogglePanel, context), KeyBinding::new(new, NewSession, context), KeyBinding::new(close, CloseSession, context), KeyBinding::new("ctrl-tab", NextSession, context), KeyBinding::new("ctrl-shift-tab", PreviousSession, context), KeyBinding::new(&format!("{digit}-9"), LastSession, context), ]); cx.bind_keys( (1..=8).map(|n| KeyBinding::new(&format!("{digit}-{n}"), SelectSession(n - 1), context)), ); } /// The main window's content: sessions, the terminal, and Git or Files, in Ely's columns. pub struct Workspace { focus: FocusHandle, sessions: Vec, /// The session shown. active: usize, files: Entity, git: Entity, /// What the window shows of the desktop, and the blur behind it. background: Blur, _settings: Subscription, _acted: Subscription, } impl Workspace { /// The window's content with its first session, `pty` started from `launch`. pub fn new( pty: Pty, events: Events, launch: Launch, window: &mut Window, cx: &mut Context, ) -> Self { let git = cx.new(|cx| GitPanel::new(launch.cwd.clone(), window, cx)); // A change the Git panel made shows in Files' marks too. let acted = cx.subscribe(&git, |this, _, _: &Acted, cx| { let folder = this.sessions[this.active].folder.clone(); this.files.update(cx, |files, cx| files.show(&folder, cx)); }); let mut workspace = Self { focus: cx.focus_handle(), sessions: Vec::new(), active: 0, files: cx.new(|_| Files::new(launch.cwd.clone())), git, background: Blur::new(window, SettingsStore::get(cx)), _settings: cx.observe_global_in::(window, |this, window, cx| { this.background.follow(window, SettingsStore::get(cx)); cx.notify(); }), _acted: acted, }; workspace.add(pty, events, launch, window, cx); workspace.select(0, window, cx); workspace } fn add( &mut self, pty: Pty, events: Events, launch: Launch, window: &mut Window, cx: &mut Context, ) { let terminal = cx.new(|cx| TerminalView::new(pty, events, window, cx)); let id = terminal.entity_id(); let events = cx.subscribe_in(&terminal, window, move |this, _, event, window, cx| { this.session_event(id, event, window, cx) }); let pid = terminal.read(cx).pid(); log::info!("app: session {pid} opens in {}", launch.cwd.display()); self.sessions.push(Session { terminal, title: String::new(), folder: launch.cwd, reports: launch.reports, bell: false, _events: events, }); } /// A session of the shell the settings name, in home or the shown session's folder. fn new_session(&mut self, window: &mut Window, cx: &mut Context) { let Some(home) = dirs::home_dir() else { log::error!("app: no home folder, so no new session"); return; }; let settings = SettingsStore::get(cx); let folder = match settings.start_in { StartIn::Home => home, StartIn::Session => self .sessions .get(self.active) .map_or(home, |session| session.folder.clone()), }; let started = launch(&settings.shell, folder, &cx.global::().0) .and_then(|launch| Ok((spawn(&launch, (80, 24), (8, 16), config(settings))?, launch))); match started { Ok(((pty, events), launch)) => { self.add(pty, events, launch, window, cx); self.select(self.sessions.len() - 1, window, cx); } Err(error) => log::error!("app: no new session: {error:#}"), } } /// Shows session `index`, clears its bell, and gives its terminal the keys. pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context) { if index >= self.sessions.len() { return; } if index != self.active && let Some(hidden) = self.sessions.get(self.active) { hidden.terminal.update(cx, |view, cx| view.let_go(cx)); } let session = &mut self.sessions[index]; session.bell = false; window.focus(&session.terminal.focus_handle(cx), cx); let folder = session.folder.clone(); self.files.update(cx, |files, cx| files.show(&folder, cx)); self.git.update(cx, |git, cx| git.show(&folder, cx)); self.active = index; log::debug!("app: session {} shown", index + 1); self.retitle(window); cx.notify(); } /// Closes session `index`; its program hangs up. The last one closes the window. pub fn close(&mut self, index: usize, window: &mut Window, cx: &mut Context) { if index >= self.sessions.len() { return; } let session = self.sessions.remove(index); log::info!("app: session {} closes", session.terminal.read(cx).pid()); if self.sessions.is_empty() { log::info!("app: no session left; the window closes"); window.remove_window(); return; } let shown = index == self.active; self.active = sessions::after_close(self.active, index, self.sessions.len()); if shown { self.select(self.active, window, cx); } else { cx.notify(); } } /// The shown session's title, or the app's name while its program names none. fn title(&self) -> &str { self.sessions .get(self.active) .map(|session| session.title.as_str()) .filter(|title| !title.is_empty()) .unwrap_or(TITLE) } fn retitle(&self, window: &mut Window) { let title = self.title(); if window.window_title() != title { log::debug!("app: the title is {title:?}"); window.set_window_title(title); } } fn session_event( &mut self, id: EntityId, event: &TerminalEvent, window: &mut Window, cx: &mut Context, ) { let Some(index) = self .sessions .iter() .position(|session| session.terminal.entity_id() == id) else { return; }; let session = &mut self.sessions[index]; match event { TerminalEvent::Title(title) => session.title.clone_from(title), TerminalEvent::Folder(folder) => { session.folder.clone_from(folder); session.reports = true; if index == self.active { self.files.update(cx, |files, cx| files.show(folder, cx)); self.git.update(cx, |git, cx| git.show(folder, cx)); } } TerminalEvent::Bell if index != self.active => { if !session.bell { log::debug!("app: a bell in hidden session {}", index + 1); } session.bell = true; } TerminalEvent::Bell => {} TerminalEvent::Exited(status) => { let pid = session.terminal.read(cx).pid(); log::info!("app: session {pid}'s program ended, {status}"); self.close(index, window, cx); return; } } if index == self.active { self.retitle(window); } cx.notify(); } /// Shows or hides a side column, as the settings keep it. fn toggle(&mut self, flip: fn(&mut Settings), cx: &mut Context) { match SettingsStore::update(cx, flip) { Ok(()) => { let settings = SettingsStore::get(cx); let shown = |on: bool| if on { "shown" } else { "hidden" }; log::info!( "app: sessions {}, right column {}", shown(settings.show_sessions), shown(settings.show_panel) ); } Err(error) => log::error!("app: the columns stay: {error:#}"), } } /// Shows the next session, or the one before, round the ends. fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { let count = self.sessions.len(); let by = if forward { 1 } else { count - 1 }; self.select((self.active + by) % count, window, cx); } } /// Shows Git or Files in the right column, as the settings keep it. fn show_tab(value: &SharedString, cx: &mut App) { let panel = match value.as_ref() { "git" => PanelTab::Git, "files" => PanelTab::Files, other => unreachable!("app: no {other} tab"), }; if SettingsStore::get(cx).panel == panel { return; } match SettingsStore::update(cx, |settings| settings.panel = panel) { Ok(()) => log::info!("app: the right column shows {panel:?}"), Err(error) => log::error!("app: the right column stays: {error:#}"), } } /// A header button that hides or shows a column, saved. fn column_toggle( workspace: &Entity, (id, icon): (&'static str, IconName), tip: String, flip: fn(&mut Settings), ) -> IconButton { let workspace = workspace.clone(); IconButton::new(id, icon) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip(tip) .on_click(move |_, _, cx| workspace.update(cx, |this, cx| this.toggle(flip, cx))) } impl Render for Workspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.background.fit(window); let settings = SettingsStore::get(cx); let width = cx.theme().sidebar_width(false); let workspace = cx.entity(); let tip = |shown: bool, what: &str| format!("{} {what}", if shown { "Hide" } else { "Show" }); // It leads the first column or ends the last. let left = column_toggle( &workspace, ("toggle-sessions", IconName::PanelLeft), tip(settings.show_sessions, "sessions"), |settings| settings.show_sessions = !settings.show_sessions, ); let right = column_toggle( &workspace, ("toggle-panel", IconName::PanelRight), tip(settings.show_panel, "Git and Files"), |settings| settings.show_panel = !settings.show_panel, ); // No menu bar off macOS: a gear opens Settings. let gear = (!cfg!(target_os = "macos")).then(|| { IconButton::new("open-settings", IconName::Settings) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("Settings") .on_click(|_, window, cx| window.dispatch_action(Box::new(OpenSettings), cx)) }); let end = div() .flex() .items_center() .gap_2() .children(gear) .child(right); let mut shell = ColumnShell::new("workspace").glass(); let shown = self .sessions .get(self.active) .map(|session| session.terminal.clone()); let mut terminal = ShellColumn::new("terminal") .title(self.title().to_string()) .children(shown); if settings.show_sessions { let home = dirs::home_dir(); let rows = self.sessions.iter().enumerate().map(|(index, session)| { let active = index == self.active; sessions::row(&workspace, index, session, active, home.as_deref(), cx) }); let list = List::new().children(rows); let new = IconButton::new("new-session", IconName::Plus) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("New session") .on_click(move |_, window, cx| { workspace.update(cx, |this, cx| this.new_session(window, cx)) }); shell = shell.column( ShellColumn::new("sessions") .width(width) .leading(left) .action(new) .child( div() .id("sessions") .size_full() .overflow_y_scroll() .p_2() .child(list), ), ); } else { terminal = terminal.leading(left); } if settings.show_panel { let tab = settings.panel; let chosen = match tab { PanelTab::Git => "git", PanelTab::Files => "files", }; let switch = SegmentedControl::new("panel-tab", chosen) .segment("git", "Git", None) .segment("files", "Files", None) .size(ControlSize::Sm) .on_change(|value, _, cx| show_tab(value, cx)); let quiet = self .sessions .get(self.active) .is_some_and(|session| !session.reports); let caption = || Caption::new("This shell reports no folder.").px_3().pt_2(); // Hidden, not dropped, so each keeps its state. let body = |on: bool| { div() .flex_1() .min_h_0() .p_2() .when(!on, |body| body.hidden()) }; // Git's list draws every row, so terminal output must not redraw it. let fill = || StyleRefinement::default().size_full(); shell = shell.column(terminal).column( ShellColumn::new("panel") .width(width) .action(end) // Below the title bar, clear of Windows' caption buttons. .child(div().flex().justify_center().px_2().pt_2().child(switch)) .children(quiet.then(caption)) .child(body(tab == PanelTab::Git).child(self.git.clone().cached(fill()))) .child(body(tab == PanelTab::Files).child(self.files.clone())), ); } else { shell = shell.column(terminal.action(end)); } // The context sits above the focus root, so its keys work wherever focus is. div() .size_full() .key_context(CONTEXT) .map(menus::window_actions) .on_action(cx.listener(|this, _: &ToggleSessions, _, cx| { this.toggle( |settings| settings.show_sessions = !settings.show_sessions, cx, ) })) .on_action(cx.listener(|this, _: &TogglePanel, _, cx| { this.toggle(|settings| settings.show_panel = !settings.show_panel, cx) })) .on_action(cx.listener(|this, _: &NewSession, window, cx| this.new_session(window, cx))) .on_action( cx.listener(|this, _: &CloseSession, window, cx| { this.close(this.active, window, cx) }), ) .on_action(cx.listener(|this, _: &NextSession, window, cx| this.step(true, window, cx))) .on_action( cx.listener(|this, _: &PreviousSession, window, cx| this.step(false, window, cx)), ) .on_action(cx.listener(|this, _: &LastSession, window, cx| { this.select(this.sessions.len().saturating_sub(1), window, cx) })) .on_action(cx.listener(|this, select: &SelectSession, window, cx| { if select.0 < this.sessions.len() { this.select(select.0, window, cx); } else { cx.propagate(); } })) .child( ResizeBorder::new() .when(self.background.see_through(), ResizeBorder::glass) .child(FocusScope::new(&self.focus).root().size_full().child(shell)), ) } }