use crate::config::{Config, SidebarSide};
use crate::paste::PasteController;
use crate::search::SearchBar;
use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
use lios_bar::{MAX_SESSIONS, SessionId, SessionWorkspace};
use std::cell::RefCell;
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use vte::prelude::*;
const MAX_SESSION_NAME_CHARS: usize = 48;
const MAX_SHELL_TITLE_CHARS: usize = 96;
const SPAWN_TIMEOUT_MS: i32 = 30_000;
type SharedWorkspace = Rc<RefCell<Workspace>>;
struct Session {
id: SessionId,
name: String,
shell_title: Option<String>,
running: bool,
exit_status: Option<i32>,
terminal: vte::Terminal,
search: SearchBar,
paste: PasteController,
spawn_cancel: gio::Cancellable,
close_dialog: Option<glib::WeakRef<gtk::MessageDialog>>,
}
struct Workspace {
window: glib::WeakRef<gtk::ApplicationWindow>,
deck: SessionWorkspace,
side_button: gtk::Button,
side: SidebarSide,
saved_config: Config,
config_path: PathBuf,
initial_directory: Option<PathBuf>,
sessions: Vec<Session>,
next_number: u32,
close_approved: bool,
close_dialog: Option<glib::WeakRef<gtk::MessageDialog>>,
}
impl Drop for Workspace {
fn drop(&mut self) {
for session in &self.sessions {
session.spawn_cancel.cancel();
}
}
}
pub(crate) fn build(
application: >k::Application,
config: Config,
config_path: PathBuf,
initial_directory: Option<PathBuf>,
) -> gtk::ApplicationWindow {
install_css();
let window = gtk::ApplicationWindow::builder()
.application(application)
.title("LiosBar")
.default_width(1180)
.default_height(760)
.build();
window.add_css_class("liosbar-window");
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.add_css_class("liosbar-header");
header.set_margin_start(10);
header.set_margin_end(10);
header.set_margin_top(8);
header.set_margin_bottom(8);
let brand = gtk::Label::new(Some("LiosBar"));
brand.add_css_class("liosbar-brand");
brand.set_halign(gtk::Align::Start);
header.append(&brand);
let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
header.append(&spacer);
let find_button = gtk::Button::with_label("Find");
find_button.set_tooltip_text(Some("Find text in the selected terminal (Ctrl+Shift+F)"));
header.append(&find_button);
let rename_button = gtk::Button::with_label("Rename");
rename_button.set_tooltip_text(Some("Rename selected session (Ctrl+Shift+R)"));
header.append(&rename_button);
let side_button = gtk::Button::with_label("");
side_button.set_tooltip_text(Some("Move sidebar left or right (Ctrl+Shift+B)"));
header.append(&side_button);
root.append(&header);
let deck = SessionWorkspace::new(widget_side(config.sidebar));
deck.set_preferences(config.preferences());
root.append(deck.widget());
window.set_child(Some(&root));
let state = Rc::new(RefCell::new(Workspace {
window: window.downgrade(),
deck,
side_button,
side: config.sidebar,
saved_config: config,
config_path,
initial_directory,
sessions: Vec::new(),
next_number: 1,
close_approved: false,
close_dialog: None,
}));
apply_sidebar_position(&state);
install_layout_persistence(&state);
{
let state = Rc::downgrade(&state);
find_button.connect_clicked(move |_| {
if let Some(state) = state.upgrade() {
open_active_search(&state);
}
});
}
{
let state = Rc::downgrade(&state);
rename_button.connect_clicked(move |_| {
if let Some(state) = state.upgrade() {
request_rename_active(&state);
}
});
}
{
let state = Rc::downgrade(&state);
let side_button = state
.upgrade()
.expect("workspace exists while wiring controls")
.borrow()
.side_button
.clone();
side_button.connect_clicked(move |_| {
if let Some(state) = state.upgrade() {
toggle_sidebar(&state);
}
});
}
{
let state = Rc::downgrade(&state);
let deck = state
.upgrade()
.expect("workspace exists while wiring list")
.borrow()
.deck
.clone();
deck.connect_selected(move |_| {
if let Some(state) = state.upgrade() {
let terminal = active_terminal(&state.borrow());
if let Some(terminal) = terminal {
terminal.grab_focus();
}
}
});
}
{
let deck = state.borrow().deck.clone();
let weak = Rc::downgrade(&state);
deck.connect_activated(move |_| {
if let Some(state) = weak.upgrade() {
let terminal = active_terminal(&state.borrow());
if let Some(terminal) = terminal {
terminal.grab_focus();
}
}
});
let weak = Rc::downgrade(&state);
deck.connect_new_requested(move || {
if let Some(state) = weak.upgrade() {
add_session(&state);
}
});
let weak = Rc::downgrade(&state);
deck.connect_close_requested(move |id| {
if let Some(state) = weak.upgrade() {
request_close_session(&state, id);
}
});
}
install_keyboard_shortcuts(&window, &state);
install_window_close_guard(&window, &state);
add_session(&state);
window
}
fn add_session(state: &SharedWorkspace) {
let (number, directory, window) = {
let workspace = state.borrow();
let Some(window) = workspace.window.upgrade() else {
return;
};
if workspace.sessions.len() >= MAX_SESSIONS {
drop(workspace);
show_message(
state,
gtk::MessageType::Warning,
"Session limit reached",
"LiosBar keeps at most 32 live or inspectable sessions. Close one before creating another.",
);
return;
}
(
workspace.next_number,
active_directory(&workspace).or_else(|| workspace.initial_directory.clone()),
window,
)
};
let name = format!("Terminal {number}");
let terminal = build_terminal();
let search = SearchBar::new(&terminal);
let content = search.wrap(&terminal);
let paste = PasteController::new(&window, &terminal);
let spawn_cancel = gio::Cancellable::new();
let deck = state.borrow().deck.clone();
let id = match deck.add(&content, &name) {
Ok(id) => id,
Err(error) => {
show_message(
state,
gtk::MessageType::Error,
"Could not add session",
&error.to_string(),
);
return;
}
};
deck.set_detail(id, "RUNNING");
{
let mut workspace = state.borrow_mut();
workspace.next_number = workspace.next_number.saturating_add(1);
workspace.sessions.push(Session {
id,
name,
shell_title: None,
running: true,
exit_status: None,
terminal: terminal.clone(),
search,
paste,
spawn_cancel: spawn_cancel.clone(),
close_dialog: None,
});
}
{
let state = Rc::downgrade(state);
terminal.connect_child_exited(move |_, status| {
if let Some(state) = state.upgrade() {
mark_exited(&state, id, status);
}
});
}
{
let state = Rc::downgrade(state);
terminal.connect_window_title_changed(move |terminal| {
let title = terminal_window_title(terminal)
.as_deref()
.and_then(bounded_shell_title);
if let Some(state) = state.upgrade() {
update_shell_title(&state, id, title);
}
});
}
spawn_shell(
&terminal,
directory.as_deref(),
Rc::downgrade(state),
id,
&spawn_cancel,
);
deck.select(id);
terminal.grab_focus();
}
fn build_terminal() -> vte::Terminal {
let terminal = vte::Terminal::new();
terminal.add_css_class("liosbar-terminal");
terminal.set_hexpand(true);
terminal.set_vexpand(true);
terminal.set_scrollback_lines(10_000);
terminal.set_scroll_on_output(false);
terminal.set_scroll_on_keystroke(true);
terminal.set_cursor_blink_mode(vte::CursorBlinkMode::Off);
terminal.set_cursor_shape(vte::CursorShape::Block);
terminal.set_font(Some(&pango::FontDescription::from_string("Monospace 12")));
let foreground = gdk::RGBA::parse("#e7e9ec").expect("static foreground color is valid");
let background = gdk::RGBA::parse("#07090c").expect("static background color is valid");
terminal.set_colors(Some(&foreground), Some(&background), &[]);
terminal
}
fn select_relative(state: &SharedWorkspace, direction: isize) {
let deck = state.borrow().deck.clone();
deck.select_relative(direction);
}
fn request_close_active(state: &SharedWorkspace) {
let Some(id) = state.borrow().deck.active() else {
return;
};
request_close_session(state, id);
}
fn request_close_session(state: &SharedWorkspace, id: SessionId) {
let (running, existing_dialog) = {
let workspace = state.borrow();
let Some(session) = workspace.sessions.iter().find(|session| session.id == id) else {
return;
};
(
session.running,
session
.close_dialog
.as_ref()
.and_then(glib::WeakRef::upgrade),
)
};
if !running {
remove_session(state, id);
return;
}
if let Some(dialog) = existing_dialog {
dialog.present();
return;
}
let Some(window) = state.borrow().window.upgrade() else {
return;
};
let dialog = gtk::MessageDialog::builder()
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::None)
.text("Close this running terminal session?")
.secondary_text(
"Its terminal widget and PTY will be closed. Unsaved shell work may be lost.",
)
.build();
dialog.add_button("Cancel", gtk::ResponseType::Cancel);
dialog.add_button("Close Session", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
if let Some(session) = state
.borrow_mut()
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.close_dialog = Some(dialog.downgrade());
}
let state = Rc::downgrade(state);
dialog.connect_response(move |dialog, response| {
let state = state.upgrade();
if let Some(state) = &state {
if let Some(session) = state
.borrow_mut()
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.close_dialog = None;
}
}
dialog.destroy();
if response == gtk::ResponseType::Accept {
if let Some(state) = state {
remove_session(&state, id);
}
}
});
dialog.present();
}
fn remove_session(state: &SharedWorkspace, id: SessionId) {
let (session, deck) = {
let mut workspace = state.borrow_mut();
let Some(index) = session_index(&workspace, id) else {
return;
};
let session = workspace.sessions.remove(index);
(session, workspace.deck.clone())
};
session.spawn_cancel.cancel();
if let Some(dialog) = session
.close_dialog
.as_ref()
.and_then(glib::WeakRef::upgrade)
{
dialog.destroy();
}
deck.remove(id);
if deck.is_empty() {
let window = state.borrow().window.upgrade();
if let Some(window) = window {
window.close();
}
}
}
fn request_rename_active(state: &SharedWorkspace) {
let (id, current, window) = {
let workspace = state.borrow();
let Some(id) = workspace.deck.active() else {
return;
};
let Some(session) = workspace.sessions.iter().find(|session| session.id == id) else {
return;
};
let Some(window) = workspace.window.upgrade() else {
return;
};
(id, session.name.clone(), window)
};
let dialog = gtk::Dialog::builder()
.title("Rename terminal session")
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.build();
dialog.add_button("Cancel", gtk::ResponseType::Cancel);
dialog.add_button("Rename", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Accept);
let entry = gtk::Entry::builder()
.text(¤t)
.max_length(MAX_SESSION_NAME_CHARS as i32)
.activates_default(true)
.margin_start(16)
.margin_end(16)
.margin_top(16)
.margin_bottom(16)
.build();
entry.select_region(0, -1);
dialog.content_area().append(&entry);
let state = Rc::downgrade(state);
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
let name = normalize_session_name(entry.text().as_str());
if let (Some(state), Some(name)) = (state.upgrade(), name) {
let mut workspace = state.borrow_mut();
if let Some(session) = workspace
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.name = name;
}
drop(workspace);
refresh_row(&state, id);
}
}
dialog.destroy();
});
dialog.present();
}
fn toggle_sidebar(state: &SharedWorkspace) {
{
let mut workspace = state.borrow_mut();
workspace.side = workspace.side.toggled();
}
apply_sidebar_position(state);
save_sidebar_choices(state);
}
fn install_layout_persistence(state: &SharedWorkspace) {
let deck = state.borrow().deck.clone();
let weak = Rc::downgrade(state);
deck.connect_preferences_changed(move |_| {
if let Some(state) = weak.upgrade() {
save_sidebar_choices(&state);
}
});
}
fn save_sidebar_choices(state: &SharedWorkspace) {
let (config, baseline, path) = {
let workspace = state.borrow();
let preferences = workspace.deck.preferences();
(
Config {
sidebar: workspace.side,
compact: preferences.compact,
show_titles: preferences.show_titles,
show_hints: preferences.show_hints,
},
workspace.saved_config.clone(),
workspace.config_path.clone(),
)
};
if let Err(error) = config.save_changes(&baseline, &path) {
show_message(
state,
gtk::MessageType::Error,
"Could not save sidebar layout",
&error,
);
} else {
state.borrow_mut().saved_config = config;
}
}
fn apply_sidebar_position(state: &SharedWorkspace) {
let (deck, side, button) = {
let workspace = state.borrow();
(
workspace.deck.clone(),
workspace.side,
workspace.side_button.clone(),
)
};
deck.set_sidebar_side(widget_side(side));
button.set_label(&format!("Sidebar: {}", side.label()));
let terminal = active_terminal(&state.borrow());
if let Some(terminal) = terminal {
terminal.grab_focus();
}
}
fn widget_side(side: SidebarSide) -> lios_bar::SidebarSide {
match side {
SidebarSide::Left => lios_bar::SidebarSide::Left,
SidebarSide::Right => lios_bar::SidebarSide::Right,
}
}
fn mark_exited(state: &SharedWorkspace, id: SessionId, status: i32) {
let mut workspace = state.borrow_mut();
if let Some(session) = workspace
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.running = false;
session.exit_status = Some(status);
}
drop(workspace);
refresh_row(state, id);
}
fn mark_spawn_failed(state: &SharedWorkspace, id: SessionId, error: &str) {
let mut workspace = state.borrow_mut();
if let Some(session) = workspace
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.running = false;
session.exit_status = None;
session.shell_title = bounded_shell_title(&format!("Spawn failed: {error}"));
}
drop(workspace);
refresh_row(state, id);
}
fn update_shell_title(state: &SharedWorkspace, id: SessionId, title: Option<String>) {
let mut workspace = state.borrow_mut();
if let Some(session) = workspace
.sessions
.iter_mut()
.find(|session| session.id == id)
{
session.shell_title = title;
}
drop(workspace);
refresh_row(state, id);
}
fn refresh_row(state: &SharedWorkspace, id: SessionId) {
let (deck, terminal, name, detail, running) = {
let workspace = state.borrow();
let Some(session) = workspace.sessions.iter().find(|session| session.id == id) else {
return;
};
let detail = if !session.running {
match session.exit_status {
Some(status) => format!("EXITED · STATUS {status}"),
None => session
.shell_title
.clone()
.unwrap_or_else(|| "EXITED".into()),
}
} else {
session
.shell_title
.clone()
.unwrap_or_else(|| "RUNNING".into())
};
(
workspace.deck.clone(),
session.terminal.clone(),
session.name.clone(),
detail,
session.running,
)
};
deck.rename(id, &name);
deck.set_detail(id, &detail);
deck.set_exited(id, !running);
terminal.set_input_enabled(running);
}
fn active_directory(workspace: &Workspace) -> Option<PathBuf> {
let active = workspace.deck.active()?;
let terminal = &workspace
.sessions
.iter()
.find(|session| session.id == active)?
.terminal;
terminal
.current_directory_uri()
.and_then(|uri| gio::File::for_uri(uri.as_str()).path())
.filter(|path| path.is_dir())
}
fn spawn_shell(
terminal: &vte::Terminal,
directory: Option<&Path>,
state: std::rc::Weak<RefCell<Workspace>>,
id: SessionId,
spawn_cancel: &gio::Cancellable,
) {
let shell = match default_shell() {
Ok(shell) => shell,
Err(error) => {
terminal.feed(format!("Could not start shell: {error}\r\n").as_bytes());
if let Some(state) = state.upgrade() {
mark_spawn_failed(&state, id, &error);
}
return;
}
};
let directory = directory
.map(Path::to_path_buf)
.or_else(|| env::current_dir().ok())
.and_then(|path| path.to_str().map(ToOwned::to_owned));
let argv = [shell.as_str()];
let environment = child_environment(directory.as_deref());
let environment_refs = environment.iter().map(String::as_str).collect::<Vec<_>>();
let terminal_for_error = terminal.downgrade();
let cancelled = spawn_cancel.clone();
terminal.spawn_async(
vte::PtyFlags::DEFAULT,
directory.as_deref(),
&argv,
&environment_refs,
glib::SpawnFlags::SEARCH_PATH,
|| {},
SPAWN_TIMEOUT_MS,
Some(spawn_cancel),
move |result| {
if cancelled.is_cancelled() {
return;
}
if let Err(error) = result {
if let Some(terminal) = terminal_for_error.upgrade() {
terminal.feed(format!("Failed to execute child: {error}\r\n").as_bytes());
}
if let Some(state) = state.upgrade() {
mark_spawn_failed(&state, id, &error.to_string());
}
}
},
);
}
fn default_shell() -> Result<String, String> {
if let Some(shell) = env::var_os("SHELL") {
let path = PathBuf::from(shell);
if is_executable(&path) {
return path
.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| "configured shell path is not valid UTF-8".to_string());
}
}
for candidate in [
"/bin/bash",
"/usr/bin/bash",
"/bin/zsh",
"/usr/bin/zsh",
"/bin/fish",
"/usr/bin/fish",
"/bin/sh",
"/usr/bin/sh",
] {
if is_executable(Path::new(candidate)) {
return Ok(candidate.to_string());
}
}
Err("no executable login shell was found".to_string())
}
fn is_executable(path: &Path) -> bool {
fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
fn child_environment(directory: Option<&str>) -> Vec<String> {
let mut output = env::vars()
.filter(|(key, _)| {
!matches!(
key.as_str(),
"TERM" | "COLORTERM" | "TERM_PROGRAM" | "TERM_PROGRAM_VERSION" | "VTE_VERSION"
)
})
.filter(|(key, _)| key != "PWD" || directory.is_none())
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
if let Some(directory) = directory {
output.push(format!("PWD={directory}"));
}
output.extend([
"TERM=xterm-256color".to_string(),
"COLORTERM=truecolor".to_string(),
"TERM_PROGRAM=lios-bar".to_string(),
format!("TERM_PROGRAM_VERSION={}", env!("CARGO_PKG_VERSION")),
]);
output
}
fn install_keyboard_shortcuts(window: >k::ApplicationWindow, state: &SharedWorkspace) {
let controller = gtk::EventControllerKey::new();
controller.set_propagation_phase(gtk::PropagationPhase::Capture);
let state = Rc::downgrade(state);
controller.connect_key_pressed(move |_, key, _, modifiers| {
let Some(state) = state.upgrade() else {
return glib::Propagation::Proceed;
};
let ctrl = modifiers.contains(gdk::ModifierType::CONTROL_MASK);
let shift = modifiers.contains(gdk::ModifierType::SHIFT_MASK);
let forbidden = modifiers.intersects(
gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK
| gdk::ModifierType::META_MASK
| gdk::ModifierType::HYPER_MASK,
);
if forbidden {
return glib::Propagation::Proceed;
}
if !ctrl && matches!(key, gdk::Key::F3 | gdk::Key::Escape) {
let search = active_search(&state.borrow());
let terminal_focused =
active_terminal(&state.borrow()).is_some_and(|terminal| terminal.has_focus());
if let Some(search) =
search.filter(|search| search.is_open() && (search.has_focus() || terminal_focused))
{
if key == gdk::Key::F3 {
search.navigate(shift);
return glib::Propagation::Stop;
}
if !shift {
search.close(true);
return glib::Propagation::Stop;
}
}
}
if !ctrl
&& shift
&& matches!(key, gdk::Key::Insert | gdk::Key::KP_Insert)
&& active_terminal(&state.borrow()).is_some_and(|terminal| terminal.has_focus())
{
request_paste(&state);
return glib::Propagation::Stop;
}
if ctrl && !shift && key == gdk::Key::Page_Down {
select_relative(&state, 1);
return glib::Propagation::Stop;
}
if ctrl && !shift && key == gdk::Key::Page_Up {
select_relative(&state, -1);
return glib::Propagation::Stop;
}
if !(ctrl && shift) {
return glib::Propagation::Proceed;
}
if matches!(key, gdk::Key::Page_Up | gdk::Key::Page_Down) {
let deck = state.borrow().deck.clone();
deck.move_active(if key == gdk::Key::Page_Up { -1 } else { 1 });
return glib::Propagation::Stop;
}
match key
.to_unicode()
.map(|character| character.to_ascii_lowercase())
{
Some('t') => add_session(&state),
Some('w') => request_close_active(&state),
Some('r') => request_rename_active(&state),
Some('b') => toggle_sidebar(&state),
Some('f') => open_active_search(&state),
Some('l') => {
let search = active_search(&state.borrow());
if let Some(search) = search {
search.close(false);
}
let deck = state.borrow().deck.clone();
deck.focus_search();
}
Some('c' | 'v') => {
if !active_terminal(&state.borrow()).is_some_and(|terminal| terminal.has_focus()) {
return glib::Propagation::Proceed;
}
if key
.to_unicode()
.is_some_and(|c| c.eq_ignore_ascii_case(&'c'))
{
copy_active(&state);
} else {
request_paste(&state);
}
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(controller);
}
fn copy_active(state: &SharedWorkspace) {
if let Some(terminal) = active_terminal(&state.borrow()) {
terminal.copy_clipboard_format(vte::Format::Text);
}
}
fn active_search(workspace: &Workspace) -> Option<SearchBar> {
let active = workspace.deck.active()?;
workspace
.sessions
.iter()
.find(|session| session.id == active)
.map(|session| session.search.clone())
}
fn open_active_search(state: &SharedWorkspace) {
let search = active_search(&state.borrow());
if let Some(search) = search {
search.open();
}
}
fn request_paste(state: &SharedWorkspace) {
let paste = {
let workspace = state.borrow();
let Some(id) = workspace.deck.active() else {
return;
};
workspace
.sessions
.iter()
.find(|session| session.id == id && session.running)
.map(|session| session.paste.clone())
};
if let Some(paste) = paste {
paste.request();
}
}
fn active_terminal(workspace: &Workspace) -> Option<vte::Terminal> {
let active = workspace.deck.active()?;
workspace
.sessions
.iter()
.find(|session| session.id == active)
.map(|session| session.terminal.clone())
}
fn install_window_close_guard(window: >k::ApplicationWindow, state: &SharedWorkspace) {
window.connect_unrealize({
let state = Rc::downgrade(state);
move |_| {
if let Some(state) = state.upgrade() {
cancel_pending_spawns(&state);
}
}
});
window.connect_destroy({
let state = Rc::downgrade(state);
move |_| {
if let Some(state) = state.upgrade() {
cancel_pending_spawns(&state);
}
}
});
let state = state.clone();
window.connect_close_request(move |window| {
let (allow, existing_dialog, running) = {
let workspace = state.borrow();
let running = workspace
.sessions
.iter()
.filter(|session| session.running)
.count();
(
workspace.close_approved || running == 0,
workspace
.close_dialog
.as_ref()
.and_then(glib::WeakRef::upgrade),
running,
)
};
if allow {
cancel_pending_spawns(&state);
return glib::Propagation::Proceed;
}
if let Some(dialog) = existing_dialog {
dialog.present();
return glib::Propagation::Stop;
}
let dialog = gtk::MessageDialog::builder()
.transient_for(window)
.modal(true)
.destroy_with_parent(true)
.message_type(gtk::MessageType::Warning)
.buttons(gtk::ButtonsType::None)
.text(format!("Close LiosBar with {running} running session(s)?"))
.secondary_text(
"All owned terminal widgets and PTYs will close. Unsaved shell work may be lost.",
)
.build();
dialog.add_button("Keep Open", gtk::ResponseType::Cancel);
dialog.add_button("Close LiosBar", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
state.borrow_mut().close_dialog = Some(dialog.downgrade());
let state_for_response = state.clone();
let window = window.downgrade();
dialog.connect_response(move |dialog, response| {
state_for_response.borrow_mut().close_dialog = None;
dialog.destroy();
if response == gtk::ResponseType::Accept {
state_for_response.borrow_mut().close_approved = true;
if let Some(window) = window.upgrade() {
window.close();
}
}
});
dialog.present();
glib::Propagation::Stop
});
}
fn cancel_pending_spawns(state: &SharedWorkspace) {
let cancellables = state
.borrow()
.sessions
.iter()
.map(|session| session.spawn_cancel.clone())
.collect::<Vec<_>>();
for cancellable in cancellables {
cancellable.cancel();
}
}
fn show_message(
state: &SharedWorkspace,
message_type: gtk::MessageType,
title: &str,
detail: &str,
) {
let Some(window) = state.borrow().window.upgrade() else {
return;
};
let dialog = gtk::MessageDialog::builder()
.transient_for(&window)
.modal(true)
.destroy_with_parent(true)
.message_type(message_type)
.buttons(gtk::ButtonsType::Close)
.text(title)
.secondary_text(detail)
.build();
dialog.connect_response(|dialog, _| dialog.destroy());
dialog.present();
}
#[allow(deprecated)]
fn terminal_window_title(terminal: &vte::Terminal) -> Option<String> {
terminal.window_title().map(|title| title.to_string())
}
fn session_index(workspace: &Workspace, id: SessionId) -> Option<usize> {
workspace
.sessions
.iter()
.position(|session| session.id == id)
}
fn normalize_session_name(value: &str) -> Option<String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let bounded = normalized
.chars()
.take(MAX_SESSION_NAME_CHARS)
.collect::<String>();
(!bounded.is_empty()).then_some(bounded)
}
fn bounded_shell_title(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
return None;
}
Some(
normalized
.chars()
.take(MAX_SHELL_TITLE_CHARS)
.collect::<String>(),
)
}
fn install_css() {
let Some(display) = gdk::Display::default() else {
return;
};
let provider = gtk::CssProvider::new();
provider.load_from_data(
"
.liosbar-window {
background: #07090c;
color: #e7e9ec;
}
.liosbar-header {
background: #05080b;
border-bottom: 1px solid #164e63;
}
.liosbar-brand {
font-weight: 800;
font-size: 16px;
color: #22d3ee;
}
.liosbar-sidebar {
background: #0a1116;
border-right: 1px solid #164e63;
border-left: 1px solid #164e63;
}
.liosbar-section-title {
color: #22d3ee;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.2px;
}
.liosbar-session-list {
background: transparent;
}
.liosbar-session-row {
color: #dce4ec;
border-radius: 8px;
margin: 2px 7px;
}
.liosbar-session-row:selected {
background: #122730;
color: #67e8f9;
}
.liosbar-session-row.liosbar-session-preview {
background: #0d2029;
box-shadow: inset 0 0 0 1px #22d3ee;
}
.liosbar-session-row.liosbar-drop-before { box-shadow: inset 0 3px #22d3ee; }
.liosbar-session-row.liosbar-drop-after { box-shadow: inset 0 -3px #22d3ee; }
.liosbar-session-name {
font-weight: 700;
}
.liosbar-session-detail,
.liosbar-footer {
color: #77899b;
font-size: 10px;
}
.liosbar-session-exited {
opacity: 0.70;
}
.liosbar-window button { background: #0e1b22; color: #e6f2f5; border-color: #164e63; }
.liosbar-window button.suggested-action { background: #164e63; color: #cffafe; border-color: #22d3ee; }
.liosbar-sidebar button:disabled { opacity: 0.45; }
.liosbar-layout-popover > contents, .liosbar-layout-popover > arrow { background: #0a1116; color: #e6f2f5; border: 1px solid #164e63; }
.liosbar-layout-popover checkbutton check { background: #05080b; color: #22d3ee; border: 1px solid #164e63; }
.liosbar-layout-popover checkbutton check:checked { background: #164e63; color: #cffafe; border-color: #22d3ee; }
.liosbar-session-filter { background: #05080b; color: #e6f2f5; border-color: #164e63; }
.liosbar-session-filter:focus-within { border-color: #22d3ee; }
.liosbar-find { padding: 10px; background: #0a1116; color: #e6f2f5; border: 1px solid #22d3ee; border-radius: 8px; }
.liosbar-find entry { background: #05080b; color: #e6f2f5; border-color: #164e63; }
.liosbar-find entry:focus-within { border-color: #22d3ee; }
.liosbar-find checkbutton check { background: #05080b; border: 1px solid #164e63; }
.liosbar-find checkbutton check:checked { background: #164e63; color: #cffafe; border-color: #22d3ee; }
.liosbar-find button:disabled { opacity: 0.45; }
.liosbar-empty { color: #8da2ad; }
.liosbar-terminal-stack,
.liosbar-terminal {
background: #07090c;
}
",
);
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires an isolated GTK display; run with Xvfb and --test-threads=1"]
fn close_guards_deduplicate_and_cancel_pending_sessions() {
gtk::init().expect("isolated GTK display is available");
install_css();
let application = gtk::Application::builder()
.application_id("dev.lios.BarLifecycleTest")
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
application.register(None::<&gio::Cancellable>).unwrap();
let fixture = || {
let window = gtk::ApplicationWindow::builder()
.application(&application)
.default_width(640)
.default_height(400)
.build();
window.add_css_class("liosbar-window");
let deck = SessionWorkspace::new(lios_bar::SidebarSide::Left);
window.set_child(Some(deck.widget()));
let mut sessions = Vec::new();
for name in ["First", "Second"] {
let terminal = build_terminal();
let search = SearchBar::new(&terminal);
let content = search.wrap(&terminal);
let paste = PasteController::new(&window, &terminal);
let id = deck.add(&content, name).unwrap();
sessions.push(Session {
id,
name: name.to_string(),
shell_title: None,
running: true,
exit_status: None,
terminal,
search,
paste,
spawn_cancel: gio::Cancellable::new(),
close_dialog: None,
});
}
let state = Rc::new(RefCell::new(Workspace {
window: window.downgrade(),
deck,
side_button: gtk::Button::new(),
side: SidebarSide::Left,
saved_config: Config::default(),
config_path: PathBuf::from("/unused-liosbar-test-config.toml"),
initial_directory: None,
sessions,
next_number: 3,
close_approved: false,
close_dialog: None,
}));
install_window_close_guard(&window, &state);
install_keyboard_shortcuts(&window, &state);
window.present();
(window, state)
};
let (window, state) = fixture();
let deck = state.borrow().deck.clone();
let original_order = deck.session_ids();
let terminal = active_terminal(&state.borrow()).unwrap();
terminal.grab_focus();
let original_focus = GtkWindowExt::focus(&window);
let controllers = window.observe_controllers();
let keys = (0..controllers.n_items())
.filter_map(|index| controllers.item(index))
.find_map(|controller| controller.downcast::<gtk::EventControllerKey>().ok())
.unwrap();
let modifiers = gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK;
let press =
|key, modifiers| keys.emit_by_name::<bool>("key-pressed", &[&key, &0_u32, &modifiers]);
crate::search::tests::settle();
assert!(!press(gdk::Key::F, modifiers | gdk::ModifierType::ALT_MASK));
assert!(press(gdk::Key::F, modifiers));
let search = active_search(&state.borrow()).unwrap();
assert!(search.is_open());
assert!(
!press(gdk::Key::V, modifiers),
"the Find entry keeps normal clipboard editing"
);
crate::search::tests::exercise(&search, &terminal);
deck.select(original_order[0]);
crate::search::tests::assert_cancelled(&search, &terminal);
assert!(!active_search(&state.borrow()).unwrap().is_open());
deck.select(original_order[1]);
assert!(press(gdk::Key::F, modifiers));
assert!(press(gdk::Key::F3, gdk::ModifierType::empty()));
assert!(press(gdk::Key::F3, gdk::ModifierType::SHIFT_MASK));
deck.focus_search();
assert!(
!press(gdk::Key::Escape, gdk::ModifierType::empty()),
"Escape in the session filter belongs to that field"
);
assert!(press(gdk::Key::F, modifiers));
assert!(press(gdk::Key::Escape, gdk::ModifierType::empty()));
assert!(!search.is_open());
assert!(
!press(gdk::Key::F3, gdk::ModifierType::empty()),
"closed search leaves terminal function keys alone"
);
assert!(press(gdk::Key::F, modifiers));
assert!(press(gdk::Key::L, modifiers));
assert!(!search.is_open());
terminal.grab_focus();
assert!(press(gdk::Key::Page_Up, modifiers));
assert_eq!(
deck.session_ids(),
vec![original_order[1], original_order[0]]
);
assert_eq!(deck.active(), Some(original_order[1]));
assert_eq!(GtkWindowExt::focus(&window), original_focus);
assert!(!press(
gdk::Key::Page_Down,
modifiers | gdk::ModifierType::ALT_MASK
));
assert_eq!(deck.session_ids()[0], original_order[1]);
assert!(press(gdk::Key::Page_Down, modifiers));
assert_eq!(deck.session_ids(), original_order);
let layout_directory =
std::env::temp_dir().join(format!("liosbar-layout-gui-{}", std::process::id()));
fs::create_dir(&layout_directory).unwrap();
let layout_path = layout_directory.join("bar.toml");
fs::create_dir(layout_directory.join("dotfiles")).unwrap();
let layout_target = layout_directory.join("dotfiles/bar.toml");
fs::write(
&layout_target,
"# Layout preferences from my dotfiles\nsidebar = 'left' # placement\n",
)
.unwrap();
std::os::unix::fs::symlink("dotfiles/bar.toml", &layout_path).unwrap();
state.borrow_mut().config_path = layout_path.clone();
install_layout_persistence(&state);
let deck = state.borrow().deck.clone();
let active = deck.active();
let choices = lios_bar::SidebarPreferences {
compact: true,
show_titles: false,
show_hints: false,
};
deck.set_preferences(choices);
assert_eq!(Config::load(&layout_path).unwrap().preferences(), choices);
toggle_sidebar(&state);
let saved = Config::load(&layout_path).unwrap();
assert_eq!(saved.sidebar, SidebarSide::Right);
assert_eq!(saved.preferences(), choices);
assert_eq!(deck.active(), active);
deck.set_preferences(Default::default());
assert_eq!(
Config::load(&layout_path).unwrap().preferences(),
Default::default()
);
assert_eq!(
Config::load(&layout_path).unwrap().sidebar,
SidebarSide::Right
);
assert_eq!(
state.borrow().sessions.len(),
2,
"layout changes never recreate shells"
);
assert!(
fs::symlink_metadata(&layout_path)
.unwrap()
.file_type()
.is_symlink()
);
let saved_text = fs::read_to_string(&layout_target).unwrap();
assert!(saved_text.contains("# Layout preferences from my dotfiles"));
assert!(saved_text.contains("# placement"));
fs::remove_dir_all(&layout_directory).unwrap();
{
let (deck, first, second, terminal) = {
let workspace = state.borrow();
(
workspace.deck.clone(),
workspace.sessions[0].id,
workspace.sessions[1].id,
workspace.sessions[1].terminal.clone(),
)
};
PasteController::exercise_session_switch(
&window,
&terminal,
|| {
deck.select(first);
},
|| {
deck.select(second);
},
);
}
let (first, first_cancel, second, second_cancel) = {
let workspace = state.borrow();
(
workspace.sessions[0].id,
workspace.sessions[0].spawn_cancel.clone(),
workspace.sessions[1].id,
workspace.sessions[1].spawn_cancel.clone(),
)
};
let session_dialog = || {
state
.borrow()
.sessions
.iter()
.find(|session| session.id == first)
.unwrap()
.close_dialog
.as_ref()
.unwrap()
.upgrade()
.unwrap()
};
request_close_session(&state, first);
let initial = session_dialog();
request_close_session(&state, first);
assert_eq!(
session_dialog(),
initial,
"reuse the pending session confirmation"
);
initial.response(gtk::ResponseType::Cancel);
assert!(
!first_cancel.is_cancelled(),
"canceling a dialog must keep the session alive"
);
request_close_session(&state, first);
let confirmed = session_dialog();
assert_ne!(confirmed, initial);
confirmed.response(gtk::ResponseType::Accept);
assert!(first_cancel.is_cancelled());
assert!(!second_cancel.is_cancelled());
assert_eq!(state.borrow().deck.active(), Some(second));
assert_eq!(state.borrow().sessions.len(), 1);
let window_dialog = || {
state
.borrow()
.close_dialog
.as_ref()
.unwrap()
.upgrade()
.unwrap()
};
assert!(window.emit_by_name::<bool>("close-request", &[]));
let initial = window_dialog();
assert!(window.emit_by_name::<bool>("close-request", &[]));
assert_eq!(
window_dialog(),
initial,
"reuse the pending window confirmation"
);
initial.response(gtk::ResponseType::Cancel);
assert!(!second_cancel.is_cancelled());
assert!(window.emit_by_name::<bool>("close-request", &[]));
window_dialog().response(gtk::ResponseType::Accept);
assert!(
second_cancel.is_cancelled(),
"approved close cancels pending startup"
);
let (window, state) = fixture();
let cancellables = state
.borrow()
.sessions
.iter()
.map(|session| session.spawn_cancel.clone())
.collect::<Vec<_>>();
window.destroy();
assert!(
cancellables.iter().all(gio::Cancellable::is_cancelled),
"direct destruction also cancels pending startup"
);
let (window, state) = fixture();
let ids = state
.borrow()
.sessions
.iter()
.map(|session| session.id)
.collect::<Vec<_>>();
for id in ids {
remove_session(&state, id);
}
assert!(
state.borrow().sessions.is_empty(),
"closing the final session must not launch a replacement shell"
);
assert!(!window.is_visible());
}
#[test]
fn session_names_are_trimmed_collapsed_and_bounded() {
assert_eq!(
normalize_session_name(" build server "),
Some("build server".to_string())
);
assert_eq!(normalize_session_name(" \t "), None);
assert_eq!(
normalize_session_name(&"x".repeat(80))
.unwrap()
.chars()
.count(),
MAX_SESSION_NAME_CHARS
);
}
#[test]
fn shell_titles_are_trimmed_and_bounded() {
assert_eq!(
bounded_shell_title(" cargo test "),
Some("cargo test".to_string())
);
assert_eq!(bounded_shell_title(" "), None);
assert_eq!(
bounded_shell_title(&"x".repeat(130))
.unwrap()
.chars()
.count(),
MAX_SHELL_TITLE_CHARS
);
}
#[test]
fn child_environment_replaces_terminal_identity() {
let environment = child_environment(Some("/tmp"));
assert!(
environment
.iter()
.any(|value| value == "TERM=xterm-256color")
);
assert!(
environment
.iter()
.any(|value| value == "TERM_PROGRAM=lios-bar")
);
assert!(environment.iter().any(|value| value == "PWD=/tmp"));
assert_eq!(
environment
.iter()
.filter(|value| value.starts_with("TERM="))
.count(),
1
);
}
}