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
+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