use crate::config::{Config, SidebarSide};
use crate::paste;
use gtk::prelude::*;
use gtk::{gdk, gio, glib, pango};
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 uuid::Uuid;
use vte::prelude::*;
const MAX_SESSIONS: usize = 32;
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: Uuid,
name: String,
shell_title: Option<String>,
running: bool,
exit_status: Option<i32>,
terminal: vte::Terminal,
row: gtk::ListBoxRow,
name_label: gtk::Label,
detail_label: gtk::Label,
}
struct Workspace {
window: glib::WeakRef<gtk::ApplicationWindow>,
paned: gtk::Paned,
sidebar: gtk::Box,
stack: gtk::Stack,
list: gtk::ListBox,
side_button: gtk::Button,
side: SidebarSide,
config_path: PathBuf,
initial_directory: Option<PathBuf>,
sessions: Vec<Session>,
active: Option<Uuid>,
next_number: u32,
close_approved: bool,
}
pub(crate) fn build(
application: >k::Application,
side: SidebarSide,
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 rename_button = gtk::Button::with_label("Rename");
rename_button.set_tooltip_text(Some("Rename selected session (Ctrl+Shift+R)"));
header.append(&rename_button);
let close_button = gtk::Button::with_label("Close");
close_button.set_tooltip_text(Some("Close selected session (Ctrl+Shift+W)"));
header.append(&close_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);
let new_button = gtk::Button::with_label("+ New Session");
new_button.add_css_class("suggested-action");
new_button.set_tooltip_text(Some("Create a terminal session (Ctrl+Shift+T)"));
header.append(&new_button);
root.append(&header);
let paned = gtk::Paned::new(gtk::Orientation::Horizontal);
paned.set_hexpand(true);
paned.set_vexpand(true);
paned.set_position(238);
paned.set_wide_handle(false);
let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 8);
sidebar.add_css_class("liosbar-sidebar");
sidebar.set_size_request(190, -1);
let sidebar_title = gtk::Label::new(Some("TERMINAL SESSIONS"));
sidebar_title.add_css_class("liosbar-section-title");
sidebar_title.set_halign(gtk::Align::Start);
sidebar_title.set_margin_start(12);
sidebar_title.set_margin_end(12);
sidebar_title.set_margin_top(12);
sidebar.append(&sidebar_title);
let list = gtk::ListBox::new();
list.add_css_class("liosbar-session-list");
list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(true);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.child(&list)
.vexpand(true)
.build();
sidebar.append(&scroll);
let footer = gtk::Label::new(Some("Sessions live while LiosBar is open"));
footer.add_css_class("liosbar-footer");
footer.set_wrap(true);
footer.set_margin_start(12);
footer.set_margin_end(12);
footer.set_margin_bottom(12);
sidebar.append(&footer);
let stack = gtk::Stack::new();
stack.add_css_class("liosbar-terminal-stack");
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.set_transition_type(gtk::StackTransitionType::Crossfade);
stack.set_transition_duration(90);
root.append(&paned);
window.set_child(Some(&root));
let state = Rc::new(RefCell::new(Workspace {
window: window.downgrade(),
paned,
sidebar,
stack,
list,
side_button,
side,
config_path,
initial_directory,
sessions: Vec::new(),
active: None,
next_number: 1,
close_approved: false,
}));
apply_sidebar_position(&state);
{
let state = Rc::downgrade(&state);
new_button.connect_clicked(move |_| {
if let Some(state) = state.upgrade() {
add_session(&state);
}
});
}
{
let state = Rc::downgrade(&state);
close_button.connect_clicked(move |_| {
if let Some(state) = state.upgrade() {
request_close_active(&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 list = state
.upgrade()
.expect("workspace exists while wiring list")
.borrow()
.list
.clone();
list.connect_row_selected(move |_, row| {
let (Some(state), Some(row)) = (state.upgrade(), row) else {
return;
};
select_index(&state, row.index().max(0) as usize);
});
}
install_keyboard_shortcuts(&window, &state);
install_window_close_guard(&window, &state);
add_session(&state);
window
}
fn add_session(state: &SharedWorkspace) {
let (number, directory) = {
let workspace = state.borrow();
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()),
)
};
let id = Uuid::now_v7();
let name = format!("Terminal {number}");
let terminal = build_terminal();
let (row, name_label, detail_label) = build_session_row(&name);
{
let mut workspace = state.borrow_mut();
workspace.next_number = workspace.next_number.saturating_add(1);
workspace.stack.add_named(&terminal, Some(&id.to_string()));
workspace.list.append(&row);
workspace.sessions.push(Session {
id,
name,
shell_title: None,
running: true,
exit_status: None,
terminal: terminal.clone(),
row: row.clone(),
name_label,
detail_label,
});
}
{
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);
let (list, index) = {
let workspace = state.borrow();
(workspace.list.clone(), session_index(&workspace, id))
};
if let Some(row) = index.and_then(|index| list.row_at_index(index as i32)) {
list.select_row(Some(&row));
}
}
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 build_session_row(name: &str) -> (gtk::ListBoxRow, gtk::Label, gtk::Label) {
let row = gtk::ListBoxRow::new();
row.add_css_class("liosbar-session-row");
let contents = gtk::Box::new(gtk::Orientation::Vertical, 2);
contents.set_margin_start(12);
contents.set_margin_end(12);
contents.set_margin_top(9);
contents.set_margin_bottom(9);
let name_label = gtk::Label::new(Some(name));
name_label.add_css_class("liosbar-session-name");
name_label.set_halign(gtk::Align::Start);
name_label.set_ellipsize(pango::EllipsizeMode::End);
contents.append(&name_label);
let detail_label = gtk::Label::new(Some("RUNNING"));
detail_label.add_css_class("liosbar-session-detail");
detail_label.set_halign(gtk::Align::Start);
detail_label.set_ellipsize(pango::EllipsizeMode::End);
contents.append(&detail_label);
row.set_child(Some(&contents));
(row, name_label, detail_label)
}
fn select_index(state: &SharedWorkspace, index: usize) {
let terminal = {
let mut workspace = state.borrow_mut();
let Some(session) = workspace.sessions.get(index) else {
return;
};
let id = session.id;
let terminal = session.terminal.clone();
workspace.active = Some(id);
workspace.stack.set_visible_child_name(&id.to_string());
terminal
};
terminal.grab_focus();
}
fn select_relative(state: &SharedWorkspace, direction: isize) {
let next = {
let workspace = state.borrow();
if workspace.sessions.is_empty() {
return;
}
let current = workspace
.active
.and_then(|id| session_index(&workspace, id))
.unwrap_or(0) as isize;
(current + direction).rem_euclid(workspace.sessions.len() as isize) as i32
};
let list = state.borrow().list.clone();
if let Some(row) = list.row_at_index(next) {
list.select_row(Some(&row));
}
}
fn request_close_active(state: &SharedWorkspace) {
let Some(id) = state.borrow().active else {
return;
};
let running = state
.borrow()
.sessions
.iter()
.find(|session| session.id == id)
.is_some_and(|session| session.running);
if !running {
remove_session(state, id);
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);
let state = Rc::downgrade(state);
dialog.connect_response(move |dialog, response| {
dialog.destroy();
if response == gtk::ResponseType::Accept {
if let Some(state) = state.upgrade() {
remove_session(&state, id);
}
}
});
dialog.present();
}
fn remove_session(state: &SharedWorkspace, id: Uuid) {
let (should_add, next_row) = {
let mut workspace = state.borrow_mut();
let Some(index) = session_index(&workspace, id) else {
return;
};
let session = workspace.sessions.remove(index);
workspace.stack.remove(&session.terminal);
workspace.list.remove(&session.row);
workspace.active = None;
let next_row = if workspace.sessions.is_empty() {
None
} else {
workspace
.list
.row_at_index(index.min(workspace.sessions.len() - 1) as i32)
};
(workspace.sessions.is_empty(), next_row)
};
if should_add {
add_session(state);
} else if let Some(row) = next_row {
let list = state.borrow().list.clone();
list.select_row(Some(&row));
}
}
fn request_rename_active(state: &SharedWorkspace) {
let (id, current, window) = {
let workspace = state.borrow();
let Some(id) = workspace.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;
refresh_row(session);
}
}
}
dialog.destroy();
});
dialog.present();
}
fn toggle_sidebar(state: &SharedWorkspace) {
{
let mut workspace = state.borrow_mut();
workspace.side = workspace.side.toggled();
}
apply_sidebar_position(state);
let (config, path) = {
let workspace = state.borrow();
(
Config {
sidebar: workspace.side,
},
workspace.config_path.clone(),
)
};
if let Err(error) = config.save(&path) {
show_message(
state,
gtk::MessageType::Error,
"Could not save sidebar position",
&error,
);
}
}
fn apply_sidebar_position(state: &SharedWorkspace) {
let workspace = state.borrow();
workspace.paned.set_start_child(None::<>k::Widget>);
workspace.paned.set_end_child(None::<>k::Widget>);
match workspace.side {
SidebarSide::Left => {
workspace.paned.set_start_child(Some(&workspace.sidebar));
workspace.paned.set_end_child(Some(&workspace.stack));
workspace.paned.set_resize_start_child(false);
workspace.paned.set_shrink_start_child(false);
workspace.paned.set_resize_end_child(true);
}
SidebarSide::Right => {
workspace.paned.set_start_child(Some(&workspace.stack));
workspace.paned.set_end_child(Some(&workspace.sidebar));
workspace.paned.set_resize_start_child(true);
workspace.paned.set_resize_end_child(false);
workspace.paned.set_shrink_end_child(false);
}
}
workspace
.side_button
.set_label(&format!("Sidebar: {}", workspace.side.label()));
}
fn mark_exited(state: &SharedWorkspace, id: Uuid, 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);
session.terminal.set_input_enabled(false);
session.row.add_css_class("liosbar-session-exited");
refresh_row(session);
}
}
fn mark_spawn_failed(state: &SharedWorkspace, id: Uuid, 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}"));
session.terminal.set_input_enabled(false);
session.row.add_css_class("liosbar-session-exited");
refresh_row(session);
}
}
fn update_shell_title(state: &SharedWorkspace, id: Uuid, 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;
refresh_row(session);
}
}
fn refresh_row(session: &Session) {
session.name_label.set_label(&session.name);
let detail = if !session.running {
match session.exit_status {
Some(status) => {
session
.detail_label
.set_label(&format!("EXITED · STATUS {status}"));
return;
}
None => session.shell_title.as_deref().unwrap_or("EXITED"),
}
} else {
session.shell_title.as_deref().unwrap_or("RUNNING")
};
session.detail_label.set_label(detail);
}
fn active_directory(workspace: &Workspace) -> Option<PathBuf> {
let active = workspace.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: Uuid,
) {
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.clone();
terminal.spawn_async(
vte::PtyFlags::DEFAULT,
directory.as_deref(),
&argv,
&environment_refs,
glib::SpawnFlags::SEARCH_PATH,
|| {},
SPAWN_TIMEOUT_MS,
gio::Cancellable::NONE,
move |result| {
if let Err(error) = result {
terminal_for_error.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,
);
if forbidden {
return glib::Propagation::Proceed;
}
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;
}
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('c') => copy_active(&state),
Some('v') => 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 request_paste(state: &SharedWorkspace) {
let (window, terminal, active_id) = {
let workspace = state.borrow();
let (Some(window), Some(terminal), Some(active_id)) = (
workspace.window.upgrade(),
active_terminal(&workspace),
workspace.active,
) else {
return;
};
(window, terminal, active_id)
};
let state = Rc::downgrade(state);
window.clipboard().read_text_async(
gio::Cancellable::NONE,
move |result: Result<Option<glib::GString>, glib::Error>| {
let (Ok(Some(text)), Some(state)) = (result, state.upgrade()) else {
return;
};
if text.is_empty() || state.borrow().active != Some(active_id) {
return;
}
let assessment = paste::classify(text.as_str());
if !assessment.requires_confirmation() {
terminal.paste_text(text.as_str());
return;
}
confirm_paste(&state, terminal.clone(), active_id, text, assessment);
},
);
}
fn confirm_paste(
state: &SharedWorkspace,
terminal: vte::Terminal,
active_id: Uuid,
text: glib::GString,
assessment: paste::Assessment,
) {
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("Confirm risky terminal paste")
.secondary_text(assessment.detail())
.build();
dialog.add_button("Cancel", gtk::ResponseType::Cancel);
dialog.add_button("Paste", gtk::ResponseType::Accept);
dialog.set_default_response(gtk::ResponseType::Cancel);
let state = Rc::downgrade(state);
dialog.connect_response(move |dialog, response| {
let active = state
.upgrade()
.is_some_and(|state| state.borrow().active == Some(active_id));
dialog.destroy();
if response == gtk::ResponseType::Accept && active {
terminal.paste_text(text.as_str());
}
});
dialog.present();
}
fn active_terminal(workspace: &Workspace) -> Option<vte::Terminal> {
let active = workspace.active?;
workspace
.sessions
.iter()
.find(|session| session.id == active)
.map(|session| session.terminal.clone())
}
fn install_window_close_guard(window: >k::ApplicationWindow, state: &SharedWorkspace) {
let state = state.clone();
window.connect_close_request(move |window| {
{
let workspace = state.borrow();
if workspace.close_approved || !workspace.sessions.iter().any(|session| session.running)
{
return glib::Propagation::Proceed;
}
}
let running = state
.borrow()
.sessions
.iter()
.filter(|session| session.running)
.count();
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);
let state_for_response = state.clone();
let window = window.downgrade();
dialog.connect_response(move |dialog, response| {
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 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: Uuid) -> 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: linear-gradient(90deg, #080b10, #101621);
border-bottom: 1px solid rgba(130, 170, 215, 0.18);
}
.liosbar-brand {
font-weight: 800;
font-size: 16px;
color: #f7f9fb;
}
.liosbar-sidebar {
background: #0a0e14;
border-right: 1px solid rgba(130, 170, 215, 0.16);
border-left: 1px solid rgba(130, 170, 215, 0.08);
}
.liosbar-section-title {
color: #7990a8;
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: linear-gradient(90deg, #17324a, #10263a);
color: #ffffff;
}
.liosbar-session-name {
font-weight: 700;
}
.liosbar-session-detail,
.liosbar-footer {
color: #77899b;
font-size: 10px;
}
.liosbar-session-exited {
opacity: 0.70;
}
.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]
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
);
}
}