a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
489 lines
17 KiB
Rust
489 lines
17 KiB
Rust
//! Resolve the global "launch a new jcode" hotkeys from config into a concrete
|
|
//! list of (chord, shell command, script file) tuples.
|
|
//!
|
|
//! There are two layers here:
|
|
//!
|
|
//! * The **pure** resolver ([`resolve_launch_hotkeys`]) turns a
|
|
//! [`LaunchHotkeysConfig`] plus the support-file paths into a list of
|
|
//! [`ResolvedLaunchHotkey`]s. An empty config reproduces jcode's historical
|
|
//! three built-in hotkeys (home / last project / self-dev), so existing
|
|
//! installs behave identically until auto-import bakes a richer per-repo
|
|
//! mapping. This layer is unit-tested without touching the machine.
|
|
//! * The **macOS** glue ([`chord_to_global_hotkey`]) maps a parsed
|
|
//! [`KeyChord`] onto a `global_hotkey::HotKey` so the launchd listener can
|
|
//! register it.
|
|
//!
|
|
//! Keeping the resolver pure means the chord/dir layout the user sees is exactly
|
|
//! what we can assert in tests, and the listener stays a thin dispatcher.
|
|
|
|
use jcode_config_types::{LaunchHotkeyEntry, LaunchHotkeysConfig};
|
|
#[cfg(any(test, target_os = "macos"))]
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
use crate::keymap::KeyChord;
|
|
|
|
/// POSIX single-quote escaping (turns `'` into `'\''`). Local copy so this
|
|
/// module compiles on every platform without pulling in the macOS-only
|
|
/// `macos_terminal` module.
|
|
fn escape_shell_single_quotes(input: &str) -> String {
|
|
input.replace('\'', r#"'\''"#)
|
|
}
|
|
|
|
/// Build a jcode launch snippet that pauses on non-zero exit so the user can
|
|
/// read the error before the terminal closes. Mirrors the macOS launcher's
|
|
/// behavior; kept local so the resolver is platform-independent.
|
|
#[cfg(any(test, target_os = "macos"))]
|
|
fn paused_jcode_shell_command_with_args(exe_path: &str, args: &[String]) -> String {
|
|
let escaped_exe = escape_shell_single_quotes(exe_path);
|
|
let mut arg_str = String::new();
|
|
for arg in args {
|
|
arg_str.push_str(" '");
|
|
arg_str.push_str(&escape_shell_single_quotes(arg));
|
|
arg_str.push('\'');
|
|
}
|
|
format!(
|
|
r#"if [ ! -x '{exe}' ]; then printf 'jcode executable not found.\n'; exit 127; fi; '{exe}'{args}; status=$?; if [ "$status" -ne 0 ]; then printf '\nJcode exited with status %s.\n' "$status"; printf 'Press Enter to close... '; read -r _; fi; exit "$status""#,
|
|
exe = escaped_exe,
|
|
args = arg_str,
|
|
)
|
|
}
|
|
|
|
/// One entry in the listener's `plan.json`: the chord to register and the script
|
|
/// to run when it fires. Written by the installer, read by the launchd listener,
|
|
/// so the listener stays a thin dispatcher that never re-parses config.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg(any(test, target_os = "macos"))]
|
|
pub(crate) struct PlanEntry {
|
|
pub chord: String,
|
|
pub script: String,
|
|
}
|
|
|
|
/// A fully-resolved launch hotkey ready to be turned into a script and a chord
|
|
/// registration.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct ResolvedLaunchHotkey {
|
|
/// jcode-style chord string, e.g. `cmd+;`.
|
|
pub chord: String,
|
|
/// Stable file name for this entry's launch script.
|
|
pub script_file_name: String,
|
|
/// Shell snippet that `cd`s into the target directory (with a `$HOME`
|
|
/// fallback) before launching jcode.
|
|
pub cd_prefix: String,
|
|
/// Original configured directory target. May be a sentinel such as `$HOME`,
|
|
/// `$LAST_DIR`, or `$LAST_REPO`; direct launchers resolve it at fire time.
|
|
pub dir: String,
|
|
/// Extra CLI args passed to jcode (e.g. `self-dev`).
|
|
pub args: Vec<String>,
|
|
/// Human label for notices.
|
|
pub label: String,
|
|
}
|
|
|
|
/// `cd` snippet that reads a directory from `dir_file` at fire time, falling
|
|
/// back to `$HOME` when the file is missing or the directory no longer exists.
|
|
fn cd_from_dir_file(dir_file: &str) -> String {
|
|
let escaped = escape_shell_single_quotes(dir_file);
|
|
format!(
|
|
"__jc_dir=\"$(cat '{escaped}' 2>/dev/null)\"; if [ -n \"$__jc_dir\" ] && [ -d \"$__jc_dir\" ]; then cd \"$__jc_dir\"; else cd \"$HOME\"; fi; "
|
|
)
|
|
}
|
|
|
|
/// `cd` snippet for a fixed absolute directory, still falling back to `$HOME` if
|
|
/// the directory has since been deleted/moved (so a stale baked path never
|
|
/// leaves the user in a broken shell).
|
|
fn cd_to_fixed_dir(dir: &str) -> String {
|
|
let escaped = escape_shell_single_quotes(dir);
|
|
format!("if [ -d '{escaped}' ]; then cd '{escaped}'; else cd \"$HOME\"; fi; ")
|
|
}
|
|
|
|
/// Translate one config entry's `dir` (path or sentinel) into a `cd` prefix.
|
|
fn cd_prefix_for_dir(dir: &str, last_dir_file: &str, last_repo_file: &str) -> String {
|
|
match dir {
|
|
"$HOME" => "cd \"$HOME\"; ".to_string(),
|
|
"$LAST_DIR" => cd_from_dir_file(last_dir_file),
|
|
"$LAST_REPO" => cd_from_dir_file(last_repo_file),
|
|
path => cd_to_fixed_dir(path),
|
|
}
|
|
}
|
|
|
|
/// A filesystem-safe, stable script name for a chord, e.g. `cmd+[` ->
|
|
/// `launch_jcode_cmd_bracketleft.sh`. Collisions are avoided by appending the
|
|
/// slot index, since two distinct entries could in theory normalize the same.
|
|
fn script_name_for(chord: &str, index: usize) -> String {
|
|
let mut slug = String::new();
|
|
for ch in chord.chars() {
|
|
match ch {
|
|
'a'..='z' | '0'..='9' => slug.push(ch),
|
|
'A'..='Z' => slug.push(ch.to_ascii_lowercase()),
|
|
'+' => slug.push('_'),
|
|
';' => slug.push_str("semicolon"),
|
|
'\'' => slug.push_str("quote"),
|
|
'[' => slug.push_str("bracketleft"),
|
|
']' => slug.push_str("bracketright"),
|
|
'\\' => slug.push_str("backslash"),
|
|
'/' => slug.push_str("slash"),
|
|
',' => slug.push_str("comma"),
|
|
'.' => slug.push_str("period"),
|
|
'-' => slug.push_str("minus"),
|
|
'=' => slug.push_str("equal"),
|
|
'`' => slug.push_str("backtick"),
|
|
_ => slug.push('x'),
|
|
}
|
|
}
|
|
format!("launch_jcode_{index}_{slug}.sh")
|
|
}
|
|
|
|
/// The built-in default entries, used when config has none. Mirrors jcode's
|
|
/// historical `Cmd+;` / `Cmd+'` / `Cmd+Shift+'` layout.
|
|
pub(crate) fn default_launch_entries() -> Vec<LaunchHotkeyEntry> {
|
|
vec![
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+;".to_string(),
|
|
dir: "$HOME".to_string(),
|
|
label: "home".to_string(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+'".to_string(),
|
|
dir: "$LAST_DIR".to_string(),
|
|
label: "last project".to_string(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+shift+'".to_string(),
|
|
dir: "$LAST_REPO".to_string(),
|
|
label: "self-dev".to_string(),
|
|
self_dev: true,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Resolve a config into concrete launch hotkeys. Empty config -> built-in
|
|
/// defaults. Invalid chords are skipped (logged by the caller). Duplicate chords
|
|
/// keep the first occurrence so a later malformed edit cannot shadow an earlier
|
|
/// binding.
|
|
pub(crate) fn resolve_launch_hotkeys(
|
|
config: &LaunchHotkeysConfig,
|
|
exe_path: &str,
|
|
last_dir_file: &str,
|
|
last_repo_file: &str,
|
|
) -> Vec<ResolvedLaunchHotkey> {
|
|
let entries: Vec<LaunchHotkeyEntry> = if config.entries.is_empty() {
|
|
default_launch_entries()
|
|
} else {
|
|
config.entries.clone()
|
|
};
|
|
|
|
let mut seen_chords: Vec<String> = Vec::new();
|
|
let mut out = Vec::new();
|
|
for (index, entry) in entries.iter().enumerate() {
|
|
let Some(chord) = KeyChord::parse(&entry.chord) else {
|
|
continue;
|
|
};
|
|
let canonical = chord.canonical();
|
|
if seen_chords.contains(&canonical) {
|
|
continue;
|
|
}
|
|
seen_chords.push(canonical.clone());
|
|
|
|
let cd_prefix = cd_prefix_for_dir(&entry.dir, last_dir_file, last_repo_file);
|
|
let args: Vec<String> = if entry.self_dev {
|
|
vec!["self-dev".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let _ = exe_path; // shell command is built by the installer; exe kept for symmetry
|
|
out.push(ResolvedLaunchHotkey {
|
|
chord: canonical,
|
|
script_file_name: script_name_for(&entry.chord, index),
|
|
cd_prefix,
|
|
dir: entry.dir.clone(),
|
|
args,
|
|
label: if entry.label.is_empty() {
|
|
entry.dir.clone()
|
|
} else {
|
|
entry.label.clone()
|
|
},
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Resolve a configured hotkey directory into a concrete cwd for direct spawns.
|
|
/// Missing/stale dynamic targets fall back to `$HOME`, matching the shell-script
|
|
/// launcher behavior.
|
|
pub(crate) fn resolve_target_dir(dir: &str, last_dir_file: &str, last_repo_file: &str) -> PathBuf {
|
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
|
match dir {
|
|
"$HOME" => home,
|
|
"$LAST_DIR" => read_existing_dir(last_dir_file).unwrap_or(home),
|
|
"$LAST_REPO" => read_existing_dir(last_repo_file).unwrap_or(home),
|
|
path => {
|
|
let expanded = if let Some(rest) = path.strip_prefix("~/") {
|
|
home.join(rest)
|
|
} else {
|
|
PathBuf::from(path)
|
|
};
|
|
if expanded.is_dir() { expanded } else { home }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_existing_dir(file: &str) -> Option<PathBuf> {
|
|
let text = std::fs::read_to_string(file).ok()?;
|
|
let path = PathBuf::from(text.trim());
|
|
path.is_dir().then_some(path)
|
|
}
|
|
|
|
/// Build the full shell command (run inside the freshly opened terminal) for a
|
|
/// resolved hotkey.
|
|
#[cfg(any(test, target_os = "macos"))]
|
|
pub(crate) fn shell_command_for(entry: &ResolvedLaunchHotkey, exe_path: &str) -> String {
|
|
format!(
|
|
"{}{}",
|
|
entry.cd_prefix,
|
|
paused_jcode_shell_command_with_args(exe_path, &entry.args)
|
|
)
|
|
}
|
|
|
|
/// Map a jcode key token onto a `global_hotkey` `Code`. Returns `None` for
|
|
/// tokens we cannot register (the caller logs and skips).
|
|
#[cfg(target_os = "macos")]
|
|
pub(crate) fn key_token_to_code(key: &str) -> Option<global_hotkey::hotkey::Code> {
|
|
use global_hotkey::hotkey::Code;
|
|
Some(match key {
|
|
";" => Code::Semicolon,
|
|
"'" => Code::Quote,
|
|
"[" => Code::BracketLeft,
|
|
"]" => Code::BracketRight,
|
|
"\\" => Code::Backslash,
|
|
"/" => Code::Slash,
|
|
"," => Code::Comma,
|
|
"." => Code::Period,
|
|
"-" => Code::Minus,
|
|
"=" => Code::Equal,
|
|
"`" => Code::Backquote,
|
|
"a" => Code::KeyA,
|
|
"b" => Code::KeyB,
|
|
"c" => Code::KeyC,
|
|
"d" => Code::KeyD,
|
|
"e" => Code::KeyE,
|
|
"f" => Code::KeyF,
|
|
"g" => Code::KeyG,
|
|
"h" => Code::KeyH,
|
|
"i" => Code::KeyI,
|
|
"j" => Code::KeyJ,
|
|
"k" => Code::KeyK,
|
|
"l" => Code::KeyL,
|
|
"m" => Code::KeyM,
|
|
"n" => Code::KeyN,
|
|
"o" => Code::KeyO,
|
|
"p" => Code::KeyP,
|
|
"q" => Code::KeyQ,
|
|
"r" => Code::KeyR,
|
|
"s" => Code::KeyS,
|
|
"t" => Code::KeyT,
|
|
"u" => Code::KeyU,
|
|
"v" => Code::KeyV,
|
|
"w" => Code::KeyW,
|
|
"x" => Code::KeyX,
|
|
"y" => Code::KeyY,
|
|
"z" => Code::KeyZ,
|
|
"0" => Code::Digit0,
|
|
"1" => Code::Digit1,
|
|
"2" => Code::Digit2,
|
|
"3" => Code::Digit3,
|
|
"4" => Code::Digit4,
|
|
"5" => Code::Digit5,
|
|
"6" => Code::Digit6,
|
|
"7" => Code::Digit7,
|
|
"8" => Code::Digit8,
|
|
"9" => Code::Digit9,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Map a parsed [`KeyChord`] onto a `global_hotkey::HotKey`. Returns `None` if
|
|
/// the key token is not registerable.
|
|
#[cfg(target_os = "macos")]
|
|
pub(crate) fn chord_to_global_hotkey(chord: &KeyChord) -> Option<global_hotkey::hotkey::HotKey> {
|
|
use global_hotkey::hotkey::{HotKey, Modifiers};
|
|
let code = key_token_to_code(&chord.key)?;
|
|
let mut mods = Modifiers::empty();
|
|
if chord.cmd {
|
|
mods |= Modifiers::META;
|
|
}
|
|
if chord.ctrl {
|
|
mods |= Modifiers::CONTROL;
|
|
}
|
|
if chord.alt {
|
|
mods |= Modifiers::ALT;
|
|
}
|
|
if chord.shift {
|
|
mods |= Modifiers::SHIFT;
|
|
}
|
|
Some(HotKey::new(Some(mods), code))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn cfg(entries: Vec<LaunchHotkeyEntry>) -> LaunchHotkeysConfig {
|
|
LaunchHotkeysConfig {
|
|
enabled: Some(true),
|
|
entries,
|
|
imported: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_config_reproduces_three_builtins() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&LaunchHotkeysConfig::default(),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
assert_eq!(resolved.len(), 3);
|
|
assert_eq!(resolved[0].chord, "cmd+;");
|
|
assert!(resolved[0].cd_prefix.contains("cd \"$HOME\""));
|
|
assert!(resolved[0].args.is_empty());
|
|
assert_eq!(resolved[1].chord, "cmd+'");
|
|
assert!(resolved[1].cd_prefix.contains("/last_dir"));
|
|
assert_eq!(resolved[2].chord, "cmd+shift+'");
|
|
assert!(resolved[2].cd_prefix.contains("/last_repo"));
|
|
assert_eq!(resolved[2].args, vec!["self-dev".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn fixed_dir_entry_cds_to_path_with_home_fallback() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&cfg(vec![LaunchHotkeyEntry {
|
|
chord: "cmd+[".to_string(),
|
|
dir: "/Users/jeremy/proj".to_string(),
|
|
label: "proj".to_string(),
|
|
self_dev: false,
|
|
}]),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].chord, "cmd+[");
|
|
assert!(resolved[0].cd_prefix.contains("/Users/jeremy/proj"));
|
|
assert!(resolved[0].cd_prefix.contains("cd \"$HOME\""));
|
|
assert_eq!(resolved[0].label, "proj");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_chords_keep_first() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&cfg(vec![
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+[".to_string(),
|
|
dir: "/a".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+[".to_string(),
|
|
dir: "/b".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
]),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
assert_eq!(resolved.len(), 1);
|
|
assert!(resolved[0].cd_prefix.contains("/a"));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_chord_is_skipped() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&cfg(vec![
|
|
LaunchHotkeyEntry {
|
|
chord: "none".to_string(),
|
|
dir: "/a".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+]".to_string(),
|
|
dir: "/b".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
]),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
assert_eq!(resolved.len(), 1);
|
|
assert_eq!(resolved[0].chord, "cmd+]");
|
|
}
|
|
|
|
#[test]
|
|
fn script_names_are_unique_and_safe() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&cfg(vec![
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+[".to_string(),
|
|
dir: "/a".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+]".to_string(),
|
|
dir: "/b".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
LaunchHotkeyEntry {
|
|
chord: "cmd+\\".to_string(),
|
|
dir: "/c".to_string(),
|
|
label: String::new(),
|
|
self_dev: false,
|
|
},
|
|
]),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
let names: Vec<&str> = resolved
|
|
.iter()
|
|
.map(|r| r.script_file_name.as_str())
|
|
.collect();
|
|
assert_eq!(names.len(), 3);
|
|
let mut sorted = names.clone();
|
|
sorted.sort();
|
|
sorted.dedup();
|
|
assert_eq!(sorted.len(), 3, "script names must be unique: {names:?}");
|
|
for n in &names {
|
|
assert!(n.ends_with(".sh"));
|
|
assert!(!n.contains(['[', ']', '\\', '/', ';', '\'']));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shell_command_includes_cd_and_exe() {
|
|
let resolved = resolve_launch_hotkeys(
|
|
&cfg(vec![LaunchHotkeyEntry {
|
|
chord: "cmd+[".to_string(),
|
|
dir: "/Users/jeremy/proj".to_string(),
|
|
label: "proj".to_string(),
|
|
self_dev: false,
|
|
}]),
|
|
"/bin/jcode",
|
|
"/last_dir",
|
|
"/last_repo",
|
|
);
|
|
let cmd = shell_command_for(&resolved[0], "/bin/jcode");
|
|
assert!(cmd.contains("/Users/jeremy/proj"));
|
|
assert!(cmd.contains("/bin/jcode"));
|
|
}
|
|
}
|