use crate::config::{Config, KeyBinding};
use crate::profile::{Profile, ProfileId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProfileSplit {
Right,
Down,
}
impl ProfileSplit {
fn as_str(self) -> &'static str {
match self {
ProfileSplit::Right => "right",
ProfileSplit::Down => "down",
}
}
fn parse(s: &str) -> Option<Self> {
match s {
"right" => Some(ProfileSplit::Right),
"down" => Some(ProfileSplit::Down),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProfileAction {
OpenTab(ProfileId),
OpenWindow(ProfileId),
Split(ProfileId, ProfileSplit),
SetTabProfile(ProfileId),
}
const OPEN_TAB: &str = "open_profile:";
const OPEN_WINDOW: &str = "open_profile_window:";
const SPLIT: &str = "split_profile:";
const SET_TAB: &str = "set_tab_profile:";
pub(crate) const PROFILE_ACTION_PREFIXES: &[&str] = &[OPEN_TAB, OPEN_WINDOW, SPLIT, SET_TAB];
impl ProfileAction {
pub(crate) fn id(self) -> String {
match self {
ProfileAction::OpenTab(id) => format!("{OPEN_TAB}{id}"),
ProfileAction::OpenWindow(id) => format!("{OPEN_WINDOW}{id}"),
ProfileAction::Split(id, dir) => format!("{SPLIT}{id}:{}", dir.as_str()),
ProfileAction::SetTabProfile(id) => format!("{SET_TAB}{id}"),
}
}
pub(crate) fn parse(action: &str) -> Option<Self> {
if let Some(rest) = action.strip_prefix(OPEN_WINDOW) {
return rest.parse().ok().map(ProfileAction::OpenWindow);
}
if let Some(rest) = action.strip_prefix(OPEN_TAB) {
return rest.parse().ok().map(ProfileAction::OpenTab);
}
if let Some(rest) = action.strip_prefix(SET_TAB) {
return rest.parse().ok().map(ProfileAction::SetTabProfile);
}
if let Some(rest) = action.strip_prefix(SPLIT) {
let (id, dir) = rest.rsplit_once(':')?;
return Some(ProfileAction::Split(
id.parse().ok()?,
ProfileSplit::parse(dir)?,
));
}
None
}
pub(crate) fn profile_id(self) -> ProfileId {
match self {
ProfileAction::OpenTab(id)
| ProfileAction::OpenWindow(id)
| ProfileAction::Split(id, _)
| ProfileAction::SetTabProfile(id) => id,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SkipReason {
Unparseable,
ChordTaken { by: String },
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ShortcutMigration {
pub(crate) migrated: Vec<(String, String)>,
pub(crate) skipped: Vec<(String, String, SkipReason)>,
}
impl ShortcutMigration {
pub(crate) fn changed(&self) -> bool {
!self.migrated.is_empty()
}
}
pub(crate) fn migrate_profile_shortcuts(
config: &mut Config,
profiles: &mut [Profile],
) -> ShortcutMigration {
let mut report = ShortcutMigration::default();
for profile in profiles.iter_mut() {
let Some(raw) = profile.keyboard_shortcut.clone() else {
continue;
};
let chord = raw.trim();
if chord.is_empty() {
profile.keyboard_shortcut = None;
continue;
}
let Ok(combo) = par_term_keybindings::parser::parse_key_combo(chord) else {
report
.skipped
.push((profile.name.clone(), raw.clone(), SkipReason::Unparseable));
continue;
};
let combo = combo.platform_normalized();
let own_action = ProfileAction::OpenTab(profile.id).id();
let taken_by = config.keybindings.iter().find_map(|kb| {
par_term_keybindings::parser::parse_key_combo(&kb.key)
.ok()
.map(par_term_keybindings::parser::KeyCombo::platform_normalized)
.filter(|other| *other == combo)
.map(|_| kb.action.clone())
});
if taken_by.as_deref() == Some(own_action.as_str()) {
profile.keyboard_shortcut = None;
report
.migrated
.push((profile.name.clone(), chord.to_string()));
continue;
}
if let Some(by) = taken_by {
report.skipped.push((
profile.name.clone(),
raw.clone(),
SkipReason::ChordTaken { by },
));
continue;
}
config.keybindings.push(KeyBinding {
key: chord.to_string(),
action: own_action,
});
profile.keyboard_shortcut = None;
report
.migrated
.push((profile.name.clone(), chord.to_string()));
}
report
}
pub(crate) fn migrate_profile_shortcuts_on_disk(config: &mut Config) {
let mut manager = match crate::profile::storage::load_profiles() {
Ok(manager) => manager,
Err(e) => {
log::warn!("Profile shortcut migration skipped: profiles did not load: {e:#}");
return;
}
};
let mut profiles = manager.to_vec();
let report = migrate_profile_shortcuts(config, &mut profiles);
for (name, chord, why) in &report.skipped {
log::warn!(
"Profile '{name}' keeps its keyboard_shortcut {chord:?}: not migrated to a \
keybinding ({why:?}). Bind open_profile:<id> in Settings › Keys instead."
);
}
if !report.changed() {
return;
}
for (name, chord) in &report.migrated {
log::info!("Migrated profile '{name}' shortcut {chord:?} to an open_profile binding");
}
manager = crate::profile::ProfileManager::from_profiles(profiles);
if let Err(e) = config.save() {
log::error!("Profile shortcut migration: saving config failed: {e:#}");
return;
}
if let Err(e) = crate::profile::storage::save_profiles(&manager) {
log::error!("Profile shortcut migration: saving profiles failed: {e:#}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(name: &str, shortcut: Option<&str>) -> Profile {
let mut p = Profile::new(name);
p.keyboard_shortcut = shortcut.map(str::to_string);
p
}
#[test]
fn action_ids_round_trip() {
let id = uuid::Uuid::new_v4();
for action in [
ProfileAction::OpenTab(id),
ProfileAction::OpenWindow(id),
ProfileAction::Split(id, ProfileSplit::Right),
ProfileAction::Split(id, ProfileSplit::Down),
ProfileAction::SetTabProfile(id),
] {
assert_eq!(ProfileAction::parse(&action.id()), Some(action));
assert_eq!(action.profile_id(), id);
}
assert!(
PROFILE_ACTION_PREFIXES
.iter()
.any(|p| ProfileAction::OpenWindow(id).id().starts_with(p))
);
}
#[test]
fn malformed_profile_actions_do_not_parse() {
let id = uuid::Uuid::new_v4();
for bad in [
"open_profile:".to_string(),
"open_profile:not-a-uuid".to_string(),
format!("split_profile:{id}"),
format!("split_profile:{id}:left"),
format!("set_tab_profile:{id}x"),
"new_tab".to_string(),
] {
assert_eq!(ProfileAction::parse(&bad), None, "{bad}");
}
}
#[test]
fn a_stored_shortcut_becomes_an_open_profile_binding_with_the_users_chord() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let mut profiles = vec![profile("Work", Some("Ctrl+Alt+W"))];
let id = profiles[0].id;
let report = migrate_profile_shortcuts(&mut config, &mut profiles);
assert_eq!(
report.migrated,
vec![("Work".to_string(), "Ctrl+Alt+W".to_string())]
);
assert!(report.skipped.is_empty());
let binding = config
.keybindings
.iter()
.find(|kb| kb.action == format!("open_profile:{id}"))
.expect("binding added");
assert_eq!(binding.key, "Ctrl+Alt+W");
assert_eq!(profiles[0].keyboard_shortcut, None, "field cleared");
let registry = par_term_keybindings::KeybindingRegistry::from_config(&config.keybindings);
assert_eq!(
registry.find_by_chord("Ctrl+Alt+W"),
Some(format!("open_profile:{id}").as_str())
);
}
#[test]
fn the_migration_runs_once() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let mut profiles = vec![profile("Work", Some("Ctrl+Alt+W"))];
migrate_profile_shortcuts(&mut config, &mut profiles);
let second = migrate_profile_shortcuts(&mut config, &mut profiles);
assert!(!second.changed());
assert_eq!(config.keybindings.len(), 1);
}
#[test]
fn a_chord_already_bound_to_the_same_profile_only_clears_the_field() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let mut profiles = vec![profile("Work", Some("Ctrl+Alt+W"))];
migrate_profile_shortcuts(&mut config, &mut profiles);
profiles[0].keyboard_shortcut = Some("Ctrl+Alt+W".to_string());
let report = migrate_profile_shortcuts(&mut config, &mut profiles);
assert!(report.skipped.is_empty());
assert_eq!(config.keybindings.len(), 1, "no second binding");
assert_eq!(profiles[0].keyboard_shortcut, None);
}
#[test]
fn a_taken_or_unparseable_chord_is_skipped_and_kept_on_the_profile() {
let mut config = Config::default();
let taken = config.keybindings[0].key.clone();
let taken_action = config.keybindings[0].action.clone();
let before = config.keybindings.len();
let mut profiles = vec![
profile("Clash", Some(&taken)),
profile("Junk", Some("Cmd+Nonsense+Q")),
];
let report = migrate_profile_shortcuts(&mut config, &mut profiles);
assert!(report.migrated.is_empty());
assert_eq!(config.keybindings.len(), before, "nothing added");
assert_eq!(
report.skipped[0].2,
SkipReason::ChordTaken { by: taken_action }
);
assert_eq!(report.skipped[1].2, SkipReason::Unparseable);
assert_eq!(
profiles[0].keyboard_shortcut.as_deref(),
Some(taken.as_str())
);
assert_eq!(
profiles[1].keyboard_shortcut.as_deref(),
Some("Cmd+Nonsense+Q")
);
}
#[test]
fn two_profiles_with_one_chord_bind_it_once() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let mut profiles = vec![
profile("First", Some("Ctrl+Alt+1")),
profile("Second", Some("ctrl+alt+1")),
];
let report = migrate_profile_shortcuts(&mut config, &mut profiles);
assert_eq!(report.migrated.len(), 1);
assert_eq!(report.skipped.len(), 1);
assert_eq!(config.keybindings.len(), 1);
}
#[test]
fn an_inherited_shortcut_is_not_bound_for_the_child() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let parent = profile("Parent", Some("Ctrl+Alt+P"));
let mut child = profile("Child", None);
child.parent_id = Some(parent.id);
let mut profiles = vec![parent, child];
let report = migrate_profile_shortcuts(&mut config, &mut profiles);
assert_eq!(report.migrated.len(), 1);
assert_eq!(config.keybindings.len(), 1);
}
#[test]
fn a_migrated_chord_is_the_profile_menu_entry_s_accelerator() {
let mut config = Config {
keybindings: Vec::new(),
..Config::default()
};
let mut profiles = vec![
profile("Work", Some("CmdOrCtrl+Alt+W")),
profile("Plain", None),
];
migrate_profile_shortcuts(&mut config, &mut profiles);
let registry = par_term_keybindings::KeybindingRegistry::from_config(&config.keybindings);
let entries = crate::menu::model::profile_entries(profiles.iter(), ®istry);
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0].action.keybinding_action().as_deref(),
Some(format!("open_profile:{}", profiles[0].id).as_str()),
"the entry dispatches the open_profile registry action"
);
assert_eq!(
entries[0].accelerator,
crate::menu::registry_accel::accelerator_from_chord("CmdOrCtrl+Alt+W"),
"the migrated chord is the menu accelerator"
);
assert_eq!(
entries[1].accelerator, None,
"a profile with no chord shows no accelerator"
);
}
}