T12: sessions: new (+, Cmd-T), close (row x, Cmd-W), select (press, Cmd-1 to 8, Cmd-9 last, Ctrl-Tab, Ctrl-Shift-Tab), Ghostty's keys off macOS; rows show the title and the folder with ~; an ended program closes its row, the last row the window, and the app quits; a bell marks a hidden row; new sessions follow the Shell and Start in settings
This commit is contained in:
6 files changed
+391
-52
No files matched your search
+11
-6
@@ -3,15 +3,15 @@ use std::borrow::Cow;
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, Focusable, TitlebarOptions, WindowBounds, WindowDecorations,
|
||||
WindowHandle, WindowOptions, px, size,
|
||||
App, AppContext, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle,
|
||||
WindowOptions, px, size,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::term::mac;
|
||||
use crate::{
|
||||
settings::SettingsStore,
|
||||
term::{ICONS, Launch, MONO, TerminalView, config, input, shell::ShellIntegration, spawn},
|
||||
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
||||
themes,
|
||||
workspace::{self, TITLE, Workspace},
|
||||
};
|
||||
@@ -41,6 +41,13 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
ShellIntegration::init(cx)?;
|
||||
input::bind_keys(cx);
|
||||
workspace::bind_keys(cx);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
log::info!("app: no window left; quitting");
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
mac::repeat_held_keys();
|
||||
@@ -74,9 +81,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
|
||||
window
|
||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||
.detach();
|
||||
let terminal = cx.new(|cx| TerminalView::new(pty, events, window, cx));
|
||||
window.focus(&terminal.focus_handle(cx), cx);
|
||||
cx.new(|cx| Workspace::new(terminal, window, cx))
|
||||
cx.new(|cx| Workspace::new(pty, events, launch.cwd, window, cx))
|
||||
})?;
|
||||
log::info!("app: main window open");
|
||||
Ok(window)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod sessions;
|
||||
pub mod settings;
|
||||
pub mod term;
|
||||
#[cfg(test)]
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
lists::ListItem,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
use gpui::{App, Entity, Subscription};
|
||||
|
||||
use crate::{
|
||||
term::TerminalView,
|
||||
workspace::{TITLE, Workspace},
|
||||
};
|
||||
|
||||
/// A program on its own terminal, as the sessions column lists it.
|
||||
pub struct Session {
|
||||
pub terminal: Entity<TerminalView>,
|
||||
/// The program's title; empty while it names none.
|
||||
pub title: String,
|
||||
/// The folder it reports, else the one it started in.
|
||||
pub folder: PathBuf,
|
||||
/// A bell rang while it was hidden.
|
||||
pub bell: bool,
|
||||
pub _events: Subscription,
|
||||
}
|
||||
|
||||
/// `folder` as a row shows it: the home folder as `~`.
|
||||
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
|
||||
match home.and_then(|home| folder.strip_prefix(home).ok()) {
|
||||
Some(rest) if rest.as_os_str().is_empty() => "~".into(),
|
||||
Some(rest) => format!("~{MAIN_SEPARATOR}{}", rest.display()),
|
||||
None => folder.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session shown once session `closed` of `left + 1` is gone, `active` having been shown.
|
||||
pub fn after_close(active: usize, closed: usize, left: usize) -> usize {
|
||||
if closed < active || active == left {
|
||||
active - 1
|
||||
} else {
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
/// Session `index`'s row: its title and folder, a bell while one rang unseen, a button that closes it.
|
||||
pub fn row(
|
||||
workspace: &Entity<Workspace>,
|
||||
index: usize,
|
||||
session: &Session,
|
||||
active: bool,
|
||||
home: Option<&Path>,
|
||||
cx: &App,
|
||||
) -> ListItem {
|
||||
let pid = session.terminal.read(cx).pid() as usize;
|
||||
let title = if session.title.is_empty() {
|
||||
TITLE
|
||||
} else {
|
||||
&session.title
|
||||
};
|
||||
let (select, close) = (workspace.clone(), workspace.clone());
|
||||
let item = ListItem::new(("session", pid), title.to_string())
|
||||
.description(shown(&session.folder, home))
|
||||
.selected(active)
|
||||
.strong(session.bell)
|
||||
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
|
||||
.trailing(
|
||||
IconButton::new(("close-session", pid), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close session")
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close.update(cx, |this, cx| this.close(index, window, cx))
|
||||
}),
|
||||
);
|
||||
if session.bell {
|
||||
item.leading(Icon::new(IconName::Bell).color(cx.theme().colors.warning))
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folders_under_home_read_from_a_tilde() {
|
||||
let home = PathBuf::from("/Users/ely");
|
||||
let shown = |folder: &str| shown(Path::new(folder), Some(&home));
|
||||
assert_eq!(shown("/Users/ely"), "~");
|
||||
assert_eq!(
|
||||
shown("/Users/ely/src/app"),
|
||||
format!("~{MAIN_SEPARATOR}src/app")
|
||||
);
|
||||
assert_eq!(
|
||||
shown("/Users/elyse"),
|
||||
"/Users/elyse",
|
||||
"a sibling is no child"
|
||||
);
|
||||
assert_eq!(shown("/tmp"), "/tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_shows_the_next_session_or_keeps_the_shown_one() {
|
||||
assert_eq!(after_close(2, 0, 3), 1, "one before it went");
|
||||
assert_eq!(after_close(1, 1, 3), 1, "it went: the next one");
|
||||
assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before");
|
||||
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
|
||||
}
|
||||
}
|
||||
+258
-44
@@ -1,19 +1,45 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
primitives::FocusScope,
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
lists::List,
|
||||
primitives::{FocusScope, IconName},
|
||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||
theme::ActiveTheme,
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Entity, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Window, actions, div,
|
||||
Action, App, AppContext, Context, EntityId, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, KeyBinding, ParentElement, Render, StatefulInteractiveElement, Styled,
|
||||
Subscription, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
settings::{Settings, SettingsStore},
|
||||
term::{TerminalEvent, TerminalView},
|
||||
sessions::{self, Session},
|
||||
settings::{Settings, SettingsStore, StartIn},
|
||||
term::{
|
||||
Events, Pty, TerminalEvent, TerminalView, config,
|
||||
shell::{ShellIntegration, launch},
|
||||
spawn,
|
||||
},
|
||||
};
|
||||
|
||||
actions!(elyterminal, [ToggleSessions, TogglePanel]);
|
||||
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.
|
||||
const CONTEXT: &str = "Workspace";
|
||||
@@ -21,61 +47,203 @@ const CONTEXT: &str = "Workspace";
|
||||
/// The window's title while the program names none.
|
||||
pub const TITLE: &str = "ELYTerminal";
|
||||
|
||||
/// Cmd-B and Cmd-Alt-B toggle the side columns; Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS.
|
||||
/// 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) = if cfg!(target_os = "macos") {
|
||||
("cmd-b", "cmd-alt-b")
|
||||
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-b",
|
||||
"ctrl-shift-alt-b",
|
||||
"ctrl-shift-t",
|
||||
"ctrl-shift-w",
|
||||
"alt",
|
||||
)
|
||||
};
|
||||
let context = Some(CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new(sessions, ToggleSessions, Some(CONTEXT)),
|
||||
KeyBinding::new(panel, TogglePanel, Some(CONTEXT)),
|
||||
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,
|
||||
terminal: Entity<TerminalView>,
|
||||
/// The program's title, or the app's name while it names none.
|
||||
title: SharedString,
|
||||
_events: Subscription,
|
||||
sessions: Vec<Session>,
|
||||
/// The session shown.
|
||||
active: usize,
|
||||
_settings: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// The window's content with its first session, `pty` started in `folder`.
|
||||
pub fn new(
|
||||
terminal: Entity<TerminalView>,
|
||||
pty: Pty,
|
||||
events: Events,
|
||||
folder: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
let mut workspace = Self {
|
||||
focus: cx.focus_handle(),
|
||||
title: TITLE.into(),
|
||||
_events: cx.subscribe_in(&terminal, window, Self::terminal_event),
|
||||
sessions: Vec::new(),
|
||||
active: 0,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
};
|
||||
workspace.add(pty, events, folder, window, cx);
|
||||
workspace.select(0, window, cx);
|
||||
workspace
|
||||
}
|
||||
|
||||
fn add(
|
||||
&mut self,
|
||||
pty: Pty,
|
||||
events: Events,
|
||||
folder: PathBuf,
|
||||
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 {}", folder.display());
|
||||
self.sessions.push(Session {
|
||||
terminal,
|
||||
title: String::new(),
|
||||
folder,
|
||||
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<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.clone(),
|
||||
&cx.global::<ShellIntegration>().0,
|
||||
)
|
||||
.and_then(|launch| spawn(&launch, (80, 24), (8, 16), config(settings)));
|
||||
match started {
|
||||
Ok((pty, events)) => {
|
||||
self.add(pty, events, folder, window, cx);
|
||||
self.select(self.sessions.len() - 1, window, cx);
|
||||
}
|
||||
Err(error) => log::error!("app: no new session: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window and the terminal's header wear the program's title.
|
||||
fn terminal_event(
|
||||
/// 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<Self>) {
|
||||
let Some(session) = self.sessions.get_mut(index) else {
|
||||
return;
|
||||
};
|
||||
session.bell = false;
|
||||
window.focus(&session.terminal.focus_handle(cx), 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
_: &Entity<TerminalView>,
|
||||
id: EntityId,
|
||||
event: &TerminalEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let TerminalEvent::Title(title) = event {
|
||||
let title = if title.is_empty() { TITLE } else { title };
|
||||
if self.title.as_ref() != title {
|
||||
log::debug!("app: the title is {title:?}");
|
||||
self.title = SharedString::from(title.to_string());
|
||||
window.set_window_title(title);
|
||||
cx.notify();
|
||||
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),
|
||||
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.
|
||||
@@ -83,23 +251,23 @@ impl Workspace {
|
||||
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 {}",
|
||||
if settings.show_sessions {
|
||||
"shown"
|
||||
} else {
|
||||
"hidden"
|
||||
},
|
||||
if settings.show_panel {
|
||||
"shown"
|
||||
} else {
|
||||
"hidden"
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
@@ -108,12 +276,38 @@ impl Render for Workspace {
|
||||
let width = cx.theme().sidebar_width(false);
|
||||
let mut shell = ColumnShell::new("workspace");
|
||||
if settings.show_sessions {
|
||||
shell = shell.column(ShellColumn::new("sessions").width(width));
|
||||
let (workspace, home) = (cx.entity(), 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).action(new).child(
|
||||
div()
|
||||
.id("sessions")
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.p_2()
|
||||
.child(list),
|
||||
),
|
||||
);
|
||||
}
|
||||
let shown = self
|
||||
.sessions
|
||||
.get(self.active)
|
||||
.map(|session| session.terminal.clone());
|
||||
shell = shell.column(
|
||||
ShellColumn::new("terminal")
|
||||
.title(self.title.clone())
|
||||
.child(self.terminal.clone()),
|
||||
.title(self.title().to_string())
|
||||
.children(shown),
|
||||
);
|
||||
if settings.show_panel {
|
||||
shell = shell.column(ShellColumn::new("panel").width(width));
|
||||
@@ -131,6 +325,26 @@ impl Render for Workspace {
|
||||
.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()
|
||||
.child(FocusScope::new(&self.focus).root().size_full().child(shell)),
|
||||
|
||||
Reference in new issue
Block a user