feat: Konsole parity phase 1b (layout save/load, SSH bookmarks)

- Serialize pane grid splits and per-tab state to RON
- Save/load layout via cosmic-files dialogs with confirmation
- SSH submenu from ~/.ssh/config Host entries (no wildcards)
- i18n en/fr for layout and SSH menu entries
This commit is contained in:
Lionel DARNIS 2026-07-09 14:18:02 +02:00
parent 43d38aed8a
commit d7cc5aaac1
5 changed files with 439 additions and 15 deletions

View file

@ -58,6 +58,9 @@ mod icon_cache;
use key_bind::key_binds;
mod key_bind;
use layout::{LayoutNode, LayoutTab};
mod layout;
mod shortcuts;
mod localize;
@ -116,6 +119,39 @@ fn monitor_update_tab_icon(
}
}
/// Concrete host names from `~/.ssh/config` (Host entries without wildcards
/// or negations), sorted and deduplicated.
fn ssh_config_hosts() -> Vec<String> {
let Some(home) = env::var_os("HOME") else {
return Vec::new();
};
let path = PathBuf::from(home).join(".ssh").join("config");
let Ok(content) = fs::read_to_string(&path) else {
return Vec::new();
};
let mut hosts = BTreeSet::new();
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let Some(keyword) = parts.next() else {
continue;
};
if !keyword.eq_ignore_ascii_case("host") {
continue;
}
for token in parts {
if token.contains(['*', '?']) || token.starts_with('!') {
continue;
}
hosts.insert(token.to_string());
}
}
hosts.into_iter().collect()
}
/// Runs application with these settings
#[rustfmt::skip]
fn main() -> Result<(), Box<dyn Error>> {
@ -332,6 +368,8 @@ pub enum Action {
Find,
KeyboardShortcuts,
LaunchUrlByMenu,
LayoutLoad,
LayoutSave,
Monitor(MonitorKind),
PaneFocusDown,
PaneFocusLeft,
@ -347,6 +385,7 @@ pub enum Action {
SaveOutput,
SelectAll,
Settings,
SshConnect(usize),
#[cfg(feature = "password_manager")]
PasswordManager,
ShowHeaderBar(bool),
@ -388,6 +427,8 @@ impl Action {
Self::Find => Message::Find(true),
Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
Self::LayoutLoad => Message::LayoutLoad,
Self::LayoutSave => Message::LayoutSave,
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
@ -405,6 +446,7 @@ impl Action {
Self::SaveOutput => Message::SaveOutput(entity_opt),
Self::SelectAll => Message::SelectAll(entity_opt),
Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
Self::SshConnect(index) => Message::SshConnect(*index),
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
Self::TabActivate0 => Message::TabActivateJump(0),
Self::TabActivate1 => Message::TabActivateJump(1),
@ -479,6 +521,12 @@ pub enum Message {
Key(Modifiers, Physical, Key),
LaunchUrl(String),
LaunchUrlByMenu,
LayoutLoad,
LayoutLoadApply,
LayoutLoadCancel,
LayoutLoadResult(DialogResult),
LayoutSave,
LayoutSaveResult(DialogResult),
Modifiers(Modifiers),
ShortcutCaptureCancel,
ShortcutCaptureStart(shortcuts::KeyBindAction),
@ -522,6 +570,7 @@ pub enum Message {
SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool),
ShowHeaderBar(bool),
SshConnect(usize),
SyntaxTheme(ColorSchemeKind, usize),
SystemThemeChange,
TabNewInheritWorkingDirectory(bool),
@ -618,6 +667,8 @@ pub struct App {
color_scheme_rename_id: widget::Id,
rename_tab: Option<(segmented_button::Entity, String)>,
rename_tab_id: widget::Id,
layout_load_pending: Option<LayoutNode>,
ssh_hosts: Vec<String>,
color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
profile_expanded: Option<ProfileId>,
show_advanced_font_settings: bool,
@ -1702,6 +1753,97 @@ impl App {
})
}
/// Snapshot the current pane grid and tabs as a serializable layout.
fn layout_from_state(&self, node: &pane_grid::Node) -> LayoutNode {
match node {
pane_grid::Node::Split {
axis, ratio, a, b, ..
} => LayoutNode::Split {
axis: (*axis).into(),
ratio: *ratio,
a: Box::new(self.layout_from_state(a)),
b: Box::new(self.layout_from_state(b)),
},
pane_grid::Node::Pane(pane) => {
let mut tabs = Vec::new();
if let Some(tab_model) = self.pane_model.panes.get(*pane) {
for entity in tab_model.iter() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
tabs.push(LayoutTab {
profile: terminal.profile_id_opt.map(|profile_id| profile_id.0),
working_directory: terminal.working_directory(),
title: terminal.tab_title_override.clone(),
});
}
}
}
if tabs.is_empty() {
tabs.push(LayoutTab::default());
}
LayoutNode::Pane { tabs }
}
}
}
/// Replace the window content with the given layout: rebuild the pane
/// grid and spawn a terminal per saved tab.
fn layout_apply(&mut self, layout: &LayoutNode) -> Task<Message> {
self.pane_model.panes.restore();
// Rebuild the pane grid; leaf order matches `LayoutNode::leaves`
self.pane_model.panes = pane_grid::State::with_configuration(
layout.configuration(&mut || segmented_button::ModelBuilder::default().build()),
);
self.terminal_ids.clear();
let panes: Vec<pane_grid::Pane> = self
.pane_model
.panes
.iter()
.map(|(pane, _)| *pane)
.collect();
for pane in &panes {
self.terminal_ids.insert(*pane, widget::Id::unique());
}
self.pane_model.panes_created = panes.len();
let mut tasks = Vec::new();
let default_tabs = vec![LayoutTab::default()];
for (pane, tabs) in panes.iter().zip(layout.leaves()) {
// Guard against hand-edited layouts with an empty tab list
let tabs = if tabs.is_empty() { &default_tabs } else { tabs };
for tab in tabs {
let profile_id_opt = tab
.profile
.map(ProfileId)
.filter(|profile_id| self.config.profiles.contains_key(profile_id));
self.startup_options = Some(tty::Options {
working_directory: tab.working_directory.clone(),
..tty::Options::default()
});
tasks.push(self.create_and_focus_new_terminal(*pane, profile_id_opt, false));
// Restore custom tab titles
if let Some(title) = &tab.title
&& let Some(tab_model) = self.pane_model.panes.get_mut(*pane)
{
let entity = tab_model.active();
tab_model.text_set(entity, title.clone());
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
terminal.lock().unwrap().tab_title_override = Some(title.clone());
}
}
}
}
if let Some(first) = panes.first() {
self.pane_model.set_focus(*first);
}
tasks.push(self.update_title(None));
tasks.push(self.update_focus());
Task::batch(tasks)
}
fn create_and_focus_new_terminal(
&mut self,
pane: pane_grid::Pane,
@ -2049,6 +2191,8 @@ impl Application for App {
color_scheme_rename_id: widget::Id::unique(),
rename_tab: None,
rename_tab_id: widget::Id::unique(),
layout_load_pending: None,
ssh_hosts: ssh_config_hosts(),
color_scheme_tab_model: widget::segmented_button::Model::default(),
profile_expanded: None,
show_advanced_font_settings: false,
@ -2691,6 +2835,78 @@ impl Application for App {
}
}
}
Message::LayoutLoad => {
if self.dialog_opt.is_none() {
let (dialog, command) = Dialog::new(
DialogSettings::new().kind(DialogKind::OpenFile),
|msg| Message::DialogMessage(Box::new(msg)),
Message::LayoutLoadResult,
);
self.dialog_opt = Some(dialog);
return command;
}
}
Message::LayoutLoadApply => {
if let Some(layout) = self.layout_load_pending.take() {
return self.layout_apply(&layout);
}
}
Message::LayoutLoadCancel => {
self.layout_load_pending = None;
self.pane_model.update_terminal_focus();
return self.update_focus();
}
Message::LayoutLoadResult(result) => {
self.dialog_opt = None;
if let DialogResult::Open(paths) = result {
let path = &paths[0];
match fs::read_to_string(path) {
Ok(content) => match ron::from_str::<LayoutNode>(&content) {
Ok(layout) => {
// Ask for confirmation before replacing sessions
self.layout_load_pending = Some(layout);
self.pane_model.unfocus_all_terminals();
}
Err(err) => {
log::error!("failed to parse layout {:?}: {}", path, err);
}
},
Err(err) => {
log::error!("failed to read layout {:?}: {}", path, err);
}
}
}
}
Message::LayoutSave => {
if self.dialog_opt.is_none() {
let (dialog, command) = Dialog::new(
DialogSettings::new().kind(DialogKind::SaveFile {
filename: String::from("cosmic-term-layout.ron"),
}),
|msg| Message::DialogMessage(Box::new(msg)),
Message::LayoutSaveResult,
);
self.dialog_opt = Some(dialog);
return command;
}
}
Message::LayoutSaveResult(result) => {
self.dialog_opt = None;
if let DialogResult::Open(paths) = result {
let path = &paths[0];
let layout = self.layout_from_state(self.pane_model.panes.layout());
match ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()) {
Ok(ron) => {
if let Err(err) = fs::write(path, ron) {
log::error!("failed to save layout to {:?}: {}", path, err);
}
}
Err(err) => {
log::error!("failed to serialize layout: {}", err);
}
}
}
}
Message::Modifiers(modifiers) => {
self.modifiers = modifiers;
}
@ -3085,6 +3301,26 @@ impl Application for App {
return self.update_config();
}
}
Message::SshConnect(index) => {
if let Some(host) = self.ssh_hosts.get(index).cloned() {
self.startup_options = Some(tty::Options {
shell: Some(tty::Shell::new("ssh".to_string(), vec![host.clone()])),
..tty::Options::default()
});
let task =
self.create_and_focus_new_terminal(self.pane_model.focused(), None, false);
// Name the tab after the host
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = tab_model.active();
tab_model.text_set(entity, host.clone());
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
terminal.lock().unwrap().tab_title_override = Some(host);
}
}
return Task::batch([task, self.update_title(None)]);
}
}
Message::ShowAdvancedFontSettings(show) => {
self.show_advanced_font_settings = show;
}
@ -3760,6 +3996,21 @@ impl Application for App {
}
fn dialog(&self) -> Option<Element<'_, Message>> {
if self.layout_load_pending.is_some() {
return Some(
widget::dialog()
.title(fl!("load-layout-title"))
.body(fl!("load-layout-body"))
.primary_action(
widget::button::destructive(fl!("load")).on_press(Message::LayoutLoadApply),
)
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::LayoutLoadCancel),
)
.into(),
);
}
if let Some((_, buffer)) = self.rename_tab.as_ref() {
let input = widget::text_input(fl!("tab-name-placeholder"), buffer)
.id(self.rename_tab_id.clone())
@ -3818,6 +4069,7 @@ impl Application for App {
&self.config,
&self.key_binds,
self.active_terminal_monitors(),
&self.ssh_hosts,
)]
}