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:
parent
43d38aed8a
commit
d7cc5aaac1
5 changed files with 439 additions and 15 deletions
252
src/main.rs
252
src/main.rs
|
|
@ -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,
|
||||
)]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue