Files
ELYTerminal/src/workspace.rs
T

496 lines
18 KiB
Rust

use std::time::Instant;
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton, SegmentedControl},
lists::List,
primitives::{FocusScope, IconName},
shell::{ColumnShell, ResizeBorder, ShellColumn},
theme::ControlSize,
typography::Caption,
};
use gpui::{
Action, App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
StyleRefinement, Styled, Subscription, Task, Window, actions, div, prelude::FluentBuilder,
};
use crate::{
blur::Blur,
files::Files,
git::panel::{Acted, GitPanel},
menus,
sessions::{self, Session, orb},
settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings},
term::{
Events, Launch, Pty, TerminalView, config,
shell::{ShellIntegration, launch},
spawn,
},
};
mod events;
mod widths;
use widths::{Dragged, Side};
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<Session>,
/// The session shown.
active: usize,
files: Entity<Files>,
git: Entity<GitPanel>,
/// What the window shows of the desktop, and the blur behind it.
background: Blur,
/// Column widths a drag shows before the settings hold them.
dragged: Dragged,
/// Draws the window at each step of the status marks while one shown moves.
ticking: Option<Task<()>>,
_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>,
) -> 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)),
dragged: Dragged::default(),
ticking: None,
_settings: cx.observe_global_in::<SettingsStore>(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<Self>,
) {
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,
waiting: false,
look: None,
_events: events,
});
}
/// Notes each session's mark `now`, and draws the window at each step while one shown moves.
fn tick(&mut self, now: Instant, cx: &mut Context<Self>) {
let mut moving = false;
for session in &mut self.sessions {
moving |= session.note(now, cx);
}
let moving = moving && SettingsStore::get(cx).show_sessions;
if moving == self.ticking.is_some() {
return;
}
log::debug!("app: status marks {}", if moving { "move" } else { "rest" });
self.ticking = moving.then(|| {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(orb::wait(Instant::now()))
.await;
if this.update(cx, |_, cx| cx.notify()).is_err() {
break;
}
}
})
});
}
/// 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<Self>) {
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::<ShellIntegration>().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 its waiting mark, and gives its terminal the keys.
pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
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);
view.set_front(false);
});
}
let session = &mut self.sessions[index];
session.bell = false;
session.waiting = false;
session.terminal.update(cx, |view, _| view.set_front(true));
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<Self>) {
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();
}
}
/// Its sessions, in their rows' order.
pub fn sessions(&self) -> &[Session] {
&self.sessions
}
/// 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);
}
}
/// Shows or hides a side column, as the settings keep it.
fn toggle(&mut self, flip: fn(&mut Settings), cx: &mut Context<Self>) {
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<Self>) {
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<Workspace>,
(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<Self>) -> impl IntoElement {
self.background.fit(window);
let now = Instant::now();
self.tick(now, cx);
let settings = SettingsStore::get(cx);
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(), now, 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(
self.side_column(Side::Sessions, cx)
.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(
self.side_column(Side::Panel, cx)
.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)),
)
}
}