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
|
|
@ -115,6 +115,12 @@ new-tab = New tab
|
||||||
new-window = New window
|
new-window = New window
|
||||||
profile = Profile
|
profile = Profile
|
||||||
menu-profiles = Profiles...
|
menu-profiles = Profiles...
|
||||||
|
ssh = SSH
|
||||||
|
save-layout = Save layout...
|
||||||
|
load-layout = Load layout...
|
||||||
|
load-layout-title = Load layout
|
||||||
|
load-layout-body = Loading a layout replaces all tabs and splits in this window.
|
||||||
|
load = Load
|
||||||
close-tab = Close tab
|
close-tab = Close tab
|
||||||
quit = Quit
|
quit = Quit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,12 @@ new-tab = Nouvel onglet
|
||||||
new-window = Nouvelle fenêtre
|
new-window = Nouvelle fenêtre
|
||||||
profile = Profil
|
profile = Profil
|
||||||
menu-profiles = Profils...
|
menu-profiles = Profils...
|
||||||
|
ssh = SSH
|
||||||
|
save-layout = Enregistrer la disposition...
|
||||||
|
load-layout = Charger une disposition...
|
||||||
|
load-layout-title = Charger une disposition
|
||||||
|
load-layout-body = Charger une disposition remplace tous les onglets et divisions de cette fenêtre.
|
||||||
|
load = Charger
|
||||||
close-tab = Fermer l'onglet
|
close-tab = Fermer l'onglet
|
||||||
quit = Quitter
|
quit = Quitter
|
||||||
|
|
||||||
|
|
|
||||||
147
src/layout.rs
Normal file
147
src/layout.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
|
//! Serializable description of a window layout: the pane grid split tree
|
||||||
|
//! and, for each pane, the list of tabs to restore.
|
||||||
|
|
||||||
|
use cosmic::widget::pane_grid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub enum LayoutAxis {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<pane_grid::Axis> for LayoutAxis {
|
||||||
|
fn from(axis: pane_grid::Axis) -> Self {
|
||||||
|
match axis {
|
||||||
|
pane_grid::Axis::Horizontal => Self::Horizontal,
|
||||||
|
pane_grid::Axis::Vertical => Self::Vertical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LayoutAxis> for pane_grid::Axis {
|
||||||
|
fn from(axis: LayoutAxis) -> Self {
|
||||||
|
match axis {
|
||||||
|
LayoutAxis::Horizontal => Self::Horizontal,
|
||||||
|
LayoutAxis::Vertical => Self::Vertical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct LayoutTab {
|
||||||
|
pub profile: Option<u64>,
|
||||||
|
pub working_directory: Option<PathBuf>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub enum LayoutNode {
|
||||||
|
Split {
|
||||||
|
axis: LayoutAxis,
|
||||||
|
ratio: f32,
|
||||||
|
a: Box<LayoutNode>,
|
||||||
|
b: Box<LayoutNode>,
|
||||||
|
},
|
||||||
|
Pane {
|
||||||
|
tabs: Vec<LayoutTab>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutNode {
|
||||||
|
/// Leaf tab lists in depth-first order (a before b), matching the pane
|
||||||
|
/// creation order of `pane_grid::State::with_configuration`.
|
||||||
|
pub fn leaves(&self) -> Vec<&Vec<LayoutTab>> {
|
||||||
|
let mut leaves = Vec::new();
|
||||||
|
self.collect_leaves(&mut leaves);
|
||||||
|
leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_leaves<'a>(&'a self, leaves: &mut Vec<&'a Vec<LayoutTab>>) {
|
||||||
|
match self {
|
||||||
|
Self::Split { a, b, .. } => {
|
||||||
|
a.collect_leaves(leaves);
|
||||||
|
b.collect_leaves(leaves);
|
||||||
|
}
|
||||||
|
Self::Pane { tabs } => leaves.push(tabs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a pane grid configuration with one empty tab model per leaf.
|
||||||
|
pub fn configuration<T, F: FnMut() -> T>(
|
||||||
|
&self,
|
||||||
|
new_model: &mut F,
|
||||||
|
) -> pane_grid::Configuration<T> {
|
||||||
|
match self {
|
||||||
|
Self::Split { axis, ratio, a, b } => pane_grid::Configuration::Split {
|
||||||
|
axis: (*axis).into(),
|
||||||
|
ratio: *ratio,
|
||||||
|
a: Box::new(a.configuration(new_model)),
|
||||||
|
b: Box::new(b.configuration(new_model)),
|
||||||
|
},
|
||||||
|
Self::Pane { .. } => pane_grid::Configuration::Pane(new_model()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> LayoutNode {
|
||||||
|
LayoutNode::Split {
|
||||||
|
axis: LayoutAxis::Vertical,
|
||||||
|
ratio: 0.6,
|
||||||
|
a: Box::new(LayoutNode::Pane {
|
||||||
|
tabs: vec![
|
||||||
|
LayoutTab {
|
||||||
|
profile: Some(1),
|
||||||
|
working_directory: Some(PathBuf::from("/tmp")),
|
||||||
|
title: Some("build".to_string()),
|
||||||
|
},
|
||||||
|
LayoutTab::default(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
b: Box::new(LayoutNode::Split {
|
||||||
|
axis: LayoutAxis::Horizontal,
|
||||||
|
ratio: 0.5,
|
||||||
|
a: Box::new(LayoutNode::Pane {
|
||||||
|
tabs: vec![LayoutTab::default()],
|
||||||
|
}),
|
||||||
|
b: Box::new(LayoutNode::Pane {
|
||||||
|
tabs: vec![LayoutTab::default()],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ron_round_trip() {
|
||||||
|
let layout = sample();
|
||||||
|
let ron = ron::ser::to_string_pretty(&layout, ron::ser::PrettyConfig::new()).unwrap();
|
||||||
|
let parsed: LayoutNode = ron::from_str(&ron).unwrap();
|
||||||
|
let leaves = parsed.leaves();
|
||||||
|
assert_eq!(leaves.len(), 3);
|
||||||
|
assert_eq!(leaves[0].len(), 2);
|
||||||
|
assert_eq!(leaves[0][0].profile, Some(1));
|
||||||
|
assert_eq!(leaves[0][0].title.as_deref(), Some("build"));
|
||||||
|
assert_eq!(
|
||||||
|
leaves[0][0].working_directory.as_deref(),
|
||||||
|
Some(std::path::Path::new("/tmp"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_depth_first_order() {
|
||||||
|
let layout = sample();
|
||||||
|
// Depth-first: pane a first (2 tabs), then the two nested panes
|
||||||
|
let leaves = layout.leaves();
|
||||||
|
assert_eq!(
|
||||||
|
leaves.iter().map(|tabs| tabs.len()).collect::<Vec<_>>(),
|
||||||
|
vec![2, 1, 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/main.rs
252
src/main.rs
|
|
@ -58,6 +58,9 @@ mod icon_cache;
|
||||||
use key_bind::key_binds;
|
use key_bind::key_binds;
|
||||||
mod key_bind;
|
mod key_bind;
|
||||||
|
|
||||||
|
use layout::{LayoutNode, LayoutTab};
|
||||||
|
mod layout;
|
||||||
|
|
||||||
mod shortcuts;
|
mod shortcuts;
|
||||||
|
|
||||||
mod localize;
|
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
|
/// Runs application with these settings
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
|
@ -332,6 +368,8 @@ pub enum Action {
|
||||||
Find,
|
Find,
|
||||||
KeyboardShortcuts,
|
KeyboardShortcuts,
|
||||||
LaunchUrlByMenu,
|
LaunchUrlByMenu,
|
||||||
|
LayoutLoad,
|
||||||
|
LayoutSave,
|
||||||
Monitor(MonitorKind),
|
Monitor(MonitorKind),
|
||||||
PaneFocusDown,
|
PaneFocusDown,
|
||||||
PaneFocusLeft,
|
PaneFocusLeft,
|
||||||
|
|
@ -347,6 +385,7 @@ pub enum Action {
|
||||||
SaveOutput,
|
SaveOutput,
|
||||||
SelectAll,
|
SelectAll,
|
||||||
Settings,
|
Settings,
|
||||||
|
SshConnect(usize),
|
||||||
#[cfg(feature = "password_manager")]
|
#[cfg(feature = "password_manager")]
|
||||||
PasswordManager,
|
PasswordManager,
|
||||||
ShowHeaderBar(bool),
|
ShowHeaderBar(bool),
|
||||||
|
|
@ -388,6 +427,8 @@ impl Action {
|
||||||
Self::Find => Message::Find(true),
|
Self::Find => Message::Find(true),
|
||||||
Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
|
Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
|
||||||
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
|
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
|
||||||
|
Self::LayoutLoad => Message::LayoutLoad,
|
||||||
|
Self::LayoutSave => Message::LayoutSave,
|
||||||
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
|
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
|
||||||
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
|
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
|
||||||
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
|
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
|
||||||
|
|
@ -405,6 +446,7 @@ impl Action {
|
||||||
Self::SaveOutput => Message::SaveOutput(entity_opt),
|
Self::SaveOutput => Message::SaveOutput(entity_opt),
|
||||||
Self::SelectAll => Message::SelectAll(entity_opt),
|
Self::SelectAll => Message::SelectAll(entity_opt),
|
||||||
Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
|
Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
|
||||||
|
Self::SshConnect(index) => Message::SshConnect(*index),
|
||||||
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
|
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
|
||||||
Self::TabActivate0 => Message::TabActivateJump(0),
|
Self::TabActivate0 => Message::TabActivateJump(0),
|
||||||
Self::TabActivate1 => Message::TabActivateJump(1),
|
Self::TabActivate1 => Message::TabActivateJump(1),
|
||||||
|
|
@ -479,6 +521,12 @@ pub enum Message {
|
||||||
Key(Modifiers, Physical, Key),
|
Key(Modifiers, Physical, Key),
|
||||||
LaunchUrl(String),
|
LaunchUrl(String),
|
||||||
LaunchUrlByMenu,
|
LaunchUrlByMenu,
|
||||||
|
LayoutLoad,
|
||||||
|
LayoutLoadApply,
|
||||||
|
LayoutLoadCancel,
|
||||||
|
LayoutLoadResult(DialogResult),
|
||||||
|
LayoutSave,
|
||||||
|
LayoutSaveResult(DialogResult),
|
||||||
Modifiers(Modifiers),
|
Modifiers(Modifiers),
|
||||||
ShortcutCaptureCancel,
|
ShortcutCaptureCancel,
|
||||||
ShortcutCaptureStart(shortcuts::KeyBindAction),
|
ShortcutCaptureStart(shortcuts::KeyBindAction),
|
||||||
|
|
@ -522,6 +570,7 @@ pub enum Message {
|
||||||
SelectAll(Option<segmented_button::Entity>),
|
SelectAll(Option<segmented_button::Entity>),
|
||||||
ShowAdvancedFontSettings(bool),
|
ShowAdvancedFontSettings(bool),
|
||||||
ShowHeaderBar(bool),
|
ShowHeaderBar(bool),
|
||||||
|
SshConnect(usize),
|
||||||
SyntaxTheme(ColorSchemeKind, usize),
|
SyntaxTheme(ColorSchemeKind, usize),
|
||||||
SystemThemeChange,
|
SystemThemeChange,
|
||||||
TabNewInheritWorkingDirectory(bool),
|
TabNewInheritWorkingDirectory(bool),
|
||||||
|
|
@ -618,6 +667,8 @@ pub struct App {
|
||||||
color_scheme_rename_id: widget::Id,
|
color_scheme_rename_id: widget::Id,
|
||||||
rename_tab: Option<(segmented_button::Entity, String)>,
|
rename_tab: Option<(segmented_button::Entity, String)>,
|
||||||
rename_tab_id: widget::Id,
|
rename_tab_id: widget::Id,
|
||||||
|
layout_load_pending: Option<LayoutNode>,
|
||||||
|
ssh_hosts: Vec<String>,
|
||||||
color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
|
color_scheme_tab_model: widget::segmented_button::SingleSelectModel,
|
||||||
profile_expanded: Option<ProfileId>,
|
profile_expanded: Option<ProfileId>,
|
||||||
show_advanced_font_settings: bool,
|
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(
|
fn create_and_focus_new_terminal(
|
||||||
&mut self,
|
&mut self,
|
||||||
pane: pane_grid::Pane,
|
pane: pane_grid::Pane,
|
||||||
|
|
@ -2049,6 +2191,8 @@ impl Application for App {
|
||||||
color_scheme_rename_id: widget::Id::unique(),
|
color_scheme_rename_id: widget::Id::unique(),
|
||||||
rename_tab: None,
|
rename_tab: None,
|
||||||
rename_tab_id: widget::Id::unique(),
|
rename_tab_id: widget::Id::unique(),
|
||||||
|
layout_load_pending: None,
|
||||||
|
ssh_hosts: ssh_config_hosts(),
|
||||||
color_scheme_tab_model: widget::segmented_button::Model::default(),
|
color_scheme_tab_model: widget::segmented_button::Model::default(),
|
||||||
profile_expanded: None,
|
profile_expanded: None,
|
||||||
show_advanced_font_settings: false,
|
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) => {
|
Message::Modifiers(modifiers) => {
|
||||||
self.modifiers = modifiers;
|
self.modifiers = modifiers;
|
||||||
}
|
}
|
||||||
|
|
@ -3085,6 +3301,26 @@ impl Application for App {
|
||||||
return self.update_config();
|
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) => {
|
Message::ShowAdvancedFontSettings(show) => {
|
||||||
self.show_advanced_font_settings = show;
|
self.show_advanced_font_settings = show;
|
||||||
}
|
}
|
||||||
|
|
@ -3760,6 +3996,21 @@ impl Application for App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dialog(&self) -> Option<Element<'_, Message>> {
|
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() {
|
if let Some((_, buffer)) = self.rename_tab.as_ref() {
|
||||||
let input = widget::text_input(fl!("tab-name-placeholder"), buffer)
|
let input = widget::text_input(fl!("tab-name-placeholder"), buffer)
|
||||||
.id(self.rename_tab_id.clone())
|
.id(self.rename_tab_id.clone())
|
||||||
|
|
@ -3818,6 +4069,7 @@ impl Application for App {
|
||||||
&self.config,
|
&self.config,
|
||||||
&self.key_binds,
|
&self.key_binds,
|
||||||
self.active_terminal_monitors(),
|
self.active_terminal_monitors(),
|
||||||
|
&self.ssh_hosts,
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
src/menu.rs
43
src/menu.rs
|
|
@ -219,6 +219,7 @@ pub fn menu_bar<'a>(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
key_binds: &HashMap<KeyBind, Action>,
|
key_binds: &HashMap<KeyBind, Action>,
|
||||||
monitors: (bool, bool, bool),
|
monitors: (bool, bool, bool),
|
||||||
|
ssh_hosts: &[String],
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let mut profile_items = Vec::with_capacity(config.profiles.len());
|
let mut profile_items = Vec::with_capacity(config.profiles.len());
|
||||||
for (name, id) in config.profile_names() {
|
for (name, id) in config.profile_names() {
|
||||||
|
|
@ -227,6 +228,32 @@ pub fn menu_bar<'a>(
|
||||||
|
|
||||||
//TODO: what to do if there are no profiles?
|
//TODO: what to do if there are no profiles?
|
||||||
|
|
||||||
|
let mut file_items = vec![
|
||||||
|
MenuItem::Button(fl!("new-tab"), None, Action::TabNew),
|
||||||
|
MenuItem::Button(fl!("new-window"), None, Action::WindowNew),
|
||||||
|
MenuItem::Divider,
|
||||||
|
MenuItem::Folder(fl!("profile"), profile_items),
|
||||||
|
MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles),
|
||||||
|
MenuItem::Divider,
|
||||||
|
];
|
||||||
|
if !ssh_hosts.is_empty() {
|
||||||
|
let ssh_items = ssh_hosts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, host)| MenuItem::Button(host.clone(), None, Action::SshConnect(index)))
|
||||||
|
.collect();
|
||||||
|
file_items.push(MenuItem::Folder(fl!("ssh"), ssh_items));
|
||||||
|
}
|
||||||
|
file_items.extend([
|
||||||
|
MenuItem::Button(fl!("save-layout"), None, Action::LayoutSave),
|
||||||
|
MenuItem::Button(fl!("load-layout"), None, Action::LayoutLoad),
|
||||||
|
MenuItem::Divider,
|
||||||
|
MenuItem::Button(fl!("rename-tab"), None, Action::TabRename),
|
||||||
|
MenuItem::Button(fl!("close-tab"), None, Action::TabClose),
|
||||||
|
MenuItem::Divider,
|
||||||
|
MenuItem::Button(fl!("quit"), None, Action::WindowClose),
|
||||||
|
]);
|
||||||
|
|
||||||
let color_scheme_kind = config.color_scheme_kind(core.system_theme());
|
let color_scheme_kind = config.color_scheme_kind(core.system_theme());
|
||||||
|
|
||||||
responsive_menu_bar()
|
responsive_menu_bar()
|
||||||
|
|
@ -239,21 +266,7 @@ pub fn menu_bar<'a>(
|
||||||
MENU_ID.clone(),
|
MENU_ID.clone(),
|
||||||
Message::Surface,
|
Message::Surface,
|
||||||
vec![
|
vec![
|
||||||
(
|
(fl!("file"), file_items),
|
||||||
fl!("file"),
|
|
||||||
vec![
|
|
||||||
MenuItem::Button(fl!("new-tab"), None, Action::TabNew),
|
|
||||||
MenuItem::Button(fl!("new-window"), None, Action::WindowNew),
|
|
||||||
MenuItem::Divider,
|
|
||||||
MenuItem::Folder(fl!("profile"), profile_items),
|
|
||||||
MenuItem::Button(fl!("menu-profiles"), None, Action::Profiles),
|
|
||||||
MenuItem::Divider,
|
|
||||||
MenuItem::Button(fl!("rename-tab"), None, Action::TabRename),
|
|
||||||
MenuItem::Button(fl!("close-tab"), None, Action::TabClose),
|
|
||||||
MenuItem::Divider,
|
|
||||||
MenuItem::Button(fl!("quit"), None, Action::WindowClose),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
fl!("edit"),
|
fl!("edit"),
|
||||||
vec![
|
vec![
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue