chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user