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,27 @@
|
||||
[package]
|
||||
name = "jcode-setup-hints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "jcode_setup_hints"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
dirs = "5"
|
||||
jcode-build-meta = { path = "../jcode-build-meta" }
|
||||
jcode-config-types = { path = "../jcode-config-types" }
|
||||
jcode-logging = { path = "../jcode-logging" }
|
||||
jcode-storage = { path = "../jcode-storage" }
|
||||
jcode-terminal-launch = { path = "../jcode-terminal-launch" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
global-hotkey = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,342 @@
|
||||
//! A normalized, platform-independent representation of a key chord.
|
||||
//!
|
||||
//! Both jcode's own bindings and the bindings we discover on the machine
|
||||
//! (terminal config, macOS system hotkeys) are reduced to a [`KeyChord`] so they
|
||||
//! can be compared for conflicts regardless of where they came from.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single key combination: a set of modifiers plus one primary key token.
|
||||
///
|
||||
/// The `key` token is stored in a canonical lowercase form (see
|
||||
/// [`KeyChord::normalize_key`]). Modifiers use jcode's vocabulary where the
|
||||
/// macOS Command key maps to `cmd` (equivalent to crossterm's `SUPER`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct KeyChord {
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub cmd: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub ctrl: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub alt: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shift: bool,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
impl KeyChord {
|
||||
/// Build a chord from a raw key token, normalizing the token.
|
||||
pub fn new(cmd: bool, ctrl: bool, alt: bool, shift: bool, key: &str) -> Self {
|
||||
Self {
|
||||
cmd,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
key: Self::normalize_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable, human-readable canonical string such as `cmd+shift+k` or
|
||||
/// `ctrl+[`. Modifier order is fixed (cmd, ctrl, alt, shift) so two chords
|
||||
/// that mean the same thing always produce the same string.
|
||||
pub fn canonical(&self) -> String {
|
||||
let mut out = String::new();
|
||||
if self.cmd {
|
||||
out.push_str("cmd+");
|
||||
}
|
||||
if self.ctrl {
|
||||
out.push_str("ctrl+");
|
||||
}
|
||||
if self.alt {
|
||||
out.push_str("alt+");
|
||||
}
|
||||
if self.shift {
|
||||
out.push_str("shift+");
|
||||
}
|
||||
out.push_str(&self.key);
|
||||
out
|
||||
}
|
||||
|
||||
/// A prettier label for user-facing messages, e.g. `Cmd+Shift+K`.
|
||||
pub fn display(&self) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if self.cmd {
|
||||
parts.push("Cmd".to_string());
|
||||
}
|
||||
if self.ctrl {
|
||||
parts.push("Ctrl".to_string());
|
||||
}
|
||||
if self.alt {
|
||||
parts.push("Alt".to_string());
|
||||
}
|
||||
if self.shift {
|
||||
parts.push("Shift".to_string());
|
||||
}
|
||||
parts.push(pretty_key(&self.key));
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
/// Like [`KeyChord::display`] but renders the `cmd` modifier as `Super`
|
||||
/// (the Linux/Wayland convention), e.g. `Super+;` or `Super+Shift+'`.
|
||||
/// Punctuation keys render as their literal character instead of the XKB
|
||||
/// name, so `Super+Semicolon` becomes `Super+;`.
|
||||
pub fn display_super(&self) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if self.cmd {
|
||||
parts.push("Super".to_string());
|
||||
}
|
||||
if self.ctrl {
|
||||
parts.push("Ctrl".to_string());
|
||||
}
|
||||
if self.alt {
|
||||
parts.push("Alt".to_string());
|
||||
}
|
||||
if self.shift {
|
||||
parts.push("Shift".to_string());
|
||||
}
|
||||
parts.push(pretty_key(&self.key));
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
/// Compact macOS-style symbol form, e.g. `⌘;` or `⌘⇧K`. Modifier symbols
|
||||
/// follow the standard Apple ordering (⌃⌥⇧⌘ is conventional, but we keep
|
||||
/// jcode's cmd-first canonical order for consistency with `display`).
|
||||
pub fn display_symbols(&self) -> String {
|
||||
let mut out = String::new();
|
||||
if self.ctrl {
|
||||
out.push('⌃');
|
||||
}
|
||||
if self.alt {
|
||||
out.push('⌥');
|
||||
}
|
||||
if self.shift {
|
||||
out.push('⇧');
|
||||
}
|
||||
if self.cmd {
|
||||
out.push('⌘');
|
||||
}
|
||||
out.push_str(&pretty_key(&self.key));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a jcode-style binding string such as `ctrl+k`, `alt+right`, or
|
||||
/// `cmd+shift+[` into a chord. Mirrors jcode's own keybinding grammar so the
|
||||
/// conflict detector compares like with like. Returns `None` for empty or
|
||||
/// explicitly-disabled bindings (`none`/`off`/`disabled`).
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
raw.to_ascii_lowercase().as_str(),
|
||||
"none" | "off" | "disabled"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cmd = false;
|
||||
let mut ctrl = false;
|
||||
let mut alt = false;
|
||||
let mut shift = false;
|
||||
let mut key: Option<String> = None;
|
||||
|
||||
for part in raw.split('+').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
match part.to_ascii_lowercase().as_str() {
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
// jcode treats alt/option/meta as Alt.
|
||||
"alt" | "option" | "meta" => alt = true,
|
||||
"cmd" | "command" | "super" | "win" | "windows" => cmd = true,
|
||||
"shift" => shift = true,
|
||||
// "backtab" / "shift-tab" imply Shift+Tab.
|
||||
"backtab" | "shift-tab" => {
|
||||
shift = true;
|
||||
key = Some("tab".to_string());
|
||||
}
|
||||
other => key = Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
let key = key?;
|
||||
Some(Self::new(cmd, ctrl, alt, shift, &key))
|
||||
}
|
||||
|
||||
/// Normalize a raw key token (from any source) into a canonical token.
|
||||
/// Handles the differing spellings used by terminals (`arrow_left`,
|
||||
/// `page_up`, `digit_1`) and macOS virtual keycodes, collapsing them onto a
|
||||
/// single vocabulary shared with jcode's own keybinding parser.
|
||||
pub fn normalize_key(raw: &str) -> String {
|
||||
let k = raw.trim().to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
// Arrows (ghostty/kitty style -> jcode style)
|
||||
"arrow_left" | "left" => "left",
|
||||
"arrow_right" | "right" => "right",
|
||||
"arrow_up" | "up" => "up",
|
||||
"arrow_down" | "down" => "down",
|
||||
// Paging / navigation
|
||||
"page_up" | "pageup" | "prior" => "pageup",
|
||||
"page_down" | "pagedown" | "next" => "pagedown",
|
||||
"home" => "home",
|
||||
"end" => "end",
|
||||
"insert" => "insert",
|
||||
"delete" | "forward_delete" => "delete",
|
||||
"backspace" => "backspace",
|
||||
"return" | "enter" => "enter",
|
||||
"escape" | "esc" => "esc",
|
||||
"tab" => "tab",
|
||||
"space" => "space",
|
||||
// Named punctuation used by various terminals
|
||||
"comma" => ",",
|
||||
"period" => ".",
|
||||
"slash" => "/",
|
||||
"backslash" => "\\",
|
||||
"semicolon" => ";",
|
||||
"apostrophe" | "quote" => "'",
|
||||
"grave" | "backtick" => "`",
|
||||
"minus" => "-",
|
||||
"equal" => "=",
|
||||
"left_bracket" | "bracketleft" => "[",
|
||||
"right_bracket" | "bracketright" => "]",
|
||||
_ => {
|
||||
// digit_N -> N
|
||||
if let Some(d) = k.strip_prefix("digit_") {
|
||||
return d.to_string();
|
||||
}
|
||||
// numpad_N -> N (best effort)
|
||||
if let Some(d) = k.strip_prefix("numpad_") {
|
||||
return d.to_string();
|
||||
}
|
||||
// Anything else (single chars, f1..f24, etc.) passes through.
|
||||
return k;
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn pretty_key(key: &str) -> String {
|
||||
match key {
|
||||
"left" => "Left".to_string(),
|
||||
"right" => "Right".to_string(),
|
||||
"up" => "Up".to_string(),
|
||||
"down" => "Down".to_string(),
|
||||
"pageup" => "PageUp".to_string(),
|
||||
"pagedown" => "PageDown".to_string(),
|
||||
"home" => "Home".to_string(),
|
||||
"end" => "End".to_string(),
|
||||
"enter" => "Enter".to_string(),
|
||||
"esc" => "Esc".to_string(),
|
||||
"tab" => "Tab".to_string(),
|
||||
"space" => "Space".to_string(),
|
||||
"backspace" => "Backspace".to_string(),
|
||||
"delete" => "Delete".to_string(),
|
||||
other => {
|
||||
if other.len() == 1 {
|
||||
other.to_ascii_uppercase()
|
||||
} else if let Some(rest) = other.strip_prefix('f') {
|
||||
if rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() {
|
||||
return format!("F{rest}");
|
||||
}
|
||||
other.to_string()
|
||||
} else {
|
||||
other.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_orders_modifiers() {
|
||||
let c = KeyChord::new(true, false, true, true, "K");
|
||||
assert_eq!(c.canonical(), "cmd+alt+shift+k");
|
||||
assert_eq!(c.display(), "Cmd+Alt+Shift+K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_super_renders_characters_not_key_names() {
|
||||
assert_eq!(KeyChord::parse("cmd+;").unwrap().display_super(), "Super+;");
|
||||
assert_eq!(
|
||||
KeyChord::parse("cmd+shift+'").unwrap().display_super(),
|
||||
"Super+Shift+'"
|
||||
);
|
||||
assert_eq!(KeyChord::parse("cmd+[").unwrap().display_super(), "Super+[");
|
||||
assert_eq!(
|
||||
KeyChord::parse("cmd+\\").unwrap().display_super(),
|
||||
"Super+\\"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_symbols_renders_modifier_glyphs() {
|
||||
assert_eq!(KeyChord::parse("cmd+;").unwrap().display_symbols(), "⌘;");
|
||||
assert_eq!(
|
||||
KeyChord::parse("cmd+shift+'").unwrap().display_symbols(),
|
||||
"⇧⌘'"
|
||||
);
|
||||
assert_eq!(
|
||||
KeyChord::parse("ctrl+alt+k").unwrap().display_symbols(),
|
||||
"⌃⌥K"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_terminal_key_spellings() {
|
||||
assert_eq!(KeyChord::normalize_key("arrow_left"), "left");
|
||||
assert_eq!(KeyChord::normalize_key("page_up"), "pageup");
|
||||
assert_eq!(KeyChord::normalize_key("digit_3"), "3");
|
||||
assert_eq!(KeyChord::normalize_key("comma"), ",");
|
||||
assert_eq!(KeyChord::normalize_key("F5"), "f5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_chords_compare_equal() {
|
||||
let a = KeyChord::new(true, false, false, false, "k");
|
||||
let b = KeyChord::new(true, false, false, false, "K");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.canonical(), b.canonical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_jcode_binding_strings() {
|
||||
assert_eq!(KeyChord::parse("ctrl+k").unwrap().canonical(), "ctrl+k");
|
||||
assert_eq!(
|
||||
KeyChord::parse("alt+right").unwrap().canonical(),
|
||||
"alt+right"
|
||||
);
|
||||
assert_eq!(
|
||||
KeyChord::parse("ctrl+shift+tab").unwrap().canonical(),
|
||||
"ctrl+shift+tab"
|
||||
);
|
||||
// Command/super alias both map to cmd.
|
||||
assert_eq!(KeyChord::parse("cmd+j").unwrap().canonical(), "cmd+j");
|
||||
assert_eq!(KeyChord::parse("super+j").unwrap().canonical(), "cmd+j");
|
||||
// backtab implies shift+tab.
|
||||
assert_eq!(KeyChord::parse("backtab").unwrap().canonical(), "shift+tab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_disabled_and_empty() {
|
||||
assert!(KeyChord::parse("").is_none());
|
||||
assert!(KeyChord::parse(" ").is_none());
|
||||
assert!(KeyChord::parse("none").is_none());
|
||||
assert!(KeyChord::parse("OFF").is_none());
|
||||
assert!(KeyChord::parse("disabled").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_matches_discovered_chord() {
|
||||
// A jcode binding and a terminal binding for the same physical keys must
|
||||
// compare equal so the conflict detector can pair them.
|
||||
let jcode = KeyChord::parse("cmd+k").unwrap();
|
||||
let terminal = KeyChord::new(true, false, false, false, "k");
|
||||
assert_eq!(jcode, terminal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! Detect conflicts between jcode's own key bindings and the bindings
|
||||
//! discovered on the machine (terminal emulator + macOS system shortcuts).
|
||||
//!
|
||||
//! The flow is:
|
||||
//! 1. enumerate jcode's configured bindings as `(field, label, KeyChord)`
|
||||
//! from [`jcode_config_types::KeybindingsConfig`] ([`jcode_bindings`]),
|
||||
//! 2. index the discovered machine bindings from a [`KeymapSnapshot`],
|
||||
//! 3. report every overlap as a [`Conflict`] that names the exact config
|
||||
//! field so a warning can point the user at the right line.
|
||||
//!
|
||||
//! All of this is pure given a `KeybindingsConfig` and a `KeymapSnapshot`, so it
|
||||
//! is fully unit-testable without touching the machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use jcode_config_types::KeybindingsConfig;
|
||||
|
||||
use super::KeymapSnapshot;
|
||||
use super::chord::KeyChord;
|
||||
use super::source::{DiscoveredBinding, KeySource};
|
||||
|
||||
/// One configured jcode binding, tied back to its config field.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JcodeBinding {
|
||||
/// The dotted config path, e.g. `keybindings.model_switch_next`.
|
||||
pub field: String,
|
||||
/// Human-friendly description of what the binding does.
|
||||
pub action: String,
|
||||
/// The configured value as written, e.g. `ctrl+tab`.
|
||||
pub raw: String,
|
||||
/// The parsed chord.
|
||||
pub chord: KeyChord,
|
||||
}
|
||||
|
||||
/// A detected conflict between a jcode binding and something on the machine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Conflict {
|
||||
/// The jcode binding that may not reach the app.
|
||||
pub jcode: JcodeBinding,
|
||||
/// The machine binding that intercepts it.
|
||||
pub interceptor: DiscoveredBinding,
|
||||
}
|
||||
|
||||
impl Conflict {
|
||||
/// A single-line, user-facing description of the conflict.
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"{} (`{}` = \"{}\") is also bound by your {} to {}",
|
||||
self.jcode.action,
|
||||
self.jcode.field,
|
||||
self.jcode.raw,
|
||||
self.interceptor_label(),
|
||||
describe_action(&self.interceptor),
|
||||
)
|
||||
}
|
||||
|
||||
/// How to refer to the thing intercepting the key. For external apps this is
|
||||
/// the specific tool name (e.g. "OmniWM"); otherwise the source label.
|
||||
fn interceptor_label(&self) -> String {
|
||||
match self.interceptor.source {
|
||||
KeySource::ExternalApp if !self.interceptor.tool.is_empty() => {
|
||||
self.interceptor.tool.clone()
|
||||
}
|
||||
other => other.label().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_action(b: &DiscoveredBinding) -> String {
|
||||
match b.source {
|
||||
KeySource::MacosSystem => b.action.clone(),
|
||||
KeySource::Terminal => {
|
||||
if b.action.is_empty() {
|
||||
"a terminal action".to_string()
|
||||
} else {
|
||||
format!("`{}`", b.action)
|
||||
}
|
||||
}
|
||||
KeySource::ExternalApp => {
|
||||
if b.action.is_empty() {
|
||||
"an app action".to_string()
|
||||
} else {
|
||||
format!("`{}`", b.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate jcode's configured bindings as comparable chords. Bindings that are
|
||||
/// disabled or fail to parse are skipped. Multi-binding fields (the workspace
|
||||
/// navigation keys accept a comma-separated list) expand into one entry per
|
||||
/// chord.
|
||||
pub fn jcode_bindings(cfg: &KeybindingsConfig) -> Vec<JcodeBinding> {
|
||||
// (field, action, raw-value) for single-chord fields.
|
||||
let single: &[(&str, &str, &str)] = &[
|
||||
("scroll_up", "Scroll up", cfg.scroll_up.as_str()),
|
||||
("scroll_down", "Scroll down", cfg.scroll_down.as_str()),
|
||||
("scroll_page_up", "Page up", cfg.scroll_page_up.as_str()),
|
||||
(
|
||||
"scroll_page_down",
|
||||
"Page down",
|
||||
cfg.scroll_page_down.as_str(),
|
||||
),
|
||||
(
|
||||
"model_switch_next",
|
||||
"Switch to next model",
|
||||
cfg.model_switch_next.as_str(),
|
||||
),
|
||||
(
|
||||
"model_switch_prev",
|
||||
"Switch to previous model",
|
||||
cfg.model_switch_prev.as_str(),
|
||||
),
|
||||
(
|
||||
"effort_increase",
|
||||
"Increase reasoning effort",
|
||||
cfg.effort_increase.as_str(),
|
||||
),
|
||||
(
|
||||
"effort_decrease",
|
||||
"Decrease reasoning effort",
|
||||
cfg.effort_decrease.as_str(),
|
||||
),
|
||||
(
|
||||
"centered_toggle",
|
||||
"Toggle centered layout",
|
||||
cfg.centered_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"scroll_prompt_up",
|
||||
"Jump to previous prompt",
|
||||
cfg.scroll_prompt_up.as_str(),
|
||||
),
|
||||
(
|
||||
"scroll_prompt_down",
|
||||
"Jump to next prompt",
|
||||
cfg.scroll_prompt_down.as_str(),
|
||||
),
|
||||
(
|
||||
"scroll_bookmark",
|
||||
"Toggle scroll bookmark",
|
||||
cfg.scroll_bookmark.as_str(),
|
||||
),
|
||||
(
|
||||
"scroll_up_fallback",
|
||||
"Scroll up (fallback)",
|
||||
cfg.scroll_up_fallback.as_str(),
|
||||
),
|
||||
(
|
||||
"scroll_down_fallback",
|
||||
"Scroll down (fallback)",
|
||||
cfg.scroll_down_fallback.as_str(),
|
||||
),
|
||||
(
|
||||
"side_panel_toggle",
|
||||
"Toggle side panel",
|
||||
cfg.side_panel_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"copy_selection_toggle",
|
||||
"Toggle copy/selection mode",
|
||||
cfg.copy_selection_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"diagram_pane_toggle",
|
||||
"Toggle diagram pane",
|
||||
cfg.diagram_pane_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"typing_scroll_lock_toggle",
|
||||
"Toggle typing scroll lock",
|
||||
cfg.typing_scroll_lock_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"diff_mode_cycle",
|
||||
"Cycle diff display mode",
|
||||
cfg.diff_mode_cycle.as_str(),
|
||||
),
|
||||
(
|
||||
"info_widget_toggle",
|
||||
"Toggle info widget",
|
||||
cfg.info_widget_toggle.as_str(),
|
||||
),
|
||||
(
|
||||
"new_terminal",
|
||||
"Spawn new terminal session",
|
||||
cfg.new_terminal.as_str(),
|
||||
),
|
||||
];
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (field, action, raw) in single {
|
||||
if let Some(chord) = KeyChord::parse(raw) {
|
||||
out.push(JcodeBinding {
|
||||
field: format!("keybindings.{field}"),
|
||||
action: action.to_string(),
|
||||
raw: raw.to_string(),
|
||||
chord,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-binding (comma-separated list) fields.
|
||||
let multi: [(&str, &str, &str); 4] = [
|
||||
(
|
||||
"workspace_left",
|
||||
"Move to left workspace",
|
||||
cfg.workspace_left.as_str(),
|
||||
),
|
||||
(
|
||||
"workspace_down",
|
||||
"Move to lower workspace",
|
||||
cfg.workspace_down.as_str(),
|
||||
),
|
||||
(
|
||||
"workspace_up",
|
||||
"Move to upper workspace",
|
||||
cfg.workspace_up.as_str(),
|
||||
),
|
||||
(
|
||||
"workspace_right",
|
||||
"Move to right workspace",
|
||||
cfg.workspace_right.as_str(),
|
||||
),
|
||||
];
|
||||
for (field, action, raw) in multi {
|
||||
for piece in raw.split(',') {
|
||||
let piece = piece.trim();
|
||||
if let Some(chord) = KeyChord::parse(piece) {
|
||||
out.push(JcodeBinding {
|
||||
field: format!("keybindings.{field}"),
|
||||
action: action.to_string(),
|
||||
raw: piece.to_string(),
|
||||
chord,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in prompt-navigation fallbacks. These are not configurable fields:
|
||||
// `ScrollKeys::prompt_jump` always honors them in addition to the configured
|
||||
// `scroll_prompt_up`/`scroll_prompt_down`. On macOS in particular, Cmd+K /
|
||||
// Cmd+J move by prompt, which is exactly what a tiling window manager tends
|
||||
// to steal for window focus, so we must enumerate them to detect that.
|
||||
let fallbacks: &[(&str, &str)] = &[
|
||||
("scroll_prompt_up", "cmd+k"),
|
||||
("scroll_prompt_down", "cmd+j"),
|
||||
("scroll_prompt_up", "cmd+["),
|
||||
("scroll_prompt_down", "cmd+]"),
|
||||
("scroll_prompt_up", "ctrl+["),
|
||||
("scroll_prompt_down", "ctrl+]"),
|
||||
];
|
||||
for (field, raw) in fallbacks {
|
||||
if let Some(chord) = KeyChord::parse(raw) {
|
||||
// Skip if a configured single-chord field already produced this exact
|
||||
// chord, to avoid duplicate conflict rows for the same keys.
|
||||
if out.iter().any(|b| b.chord == chord) {
|
||||
continue;
|
||||
}
|
||||
let action = if *field == "scroll_prompt_up" {
|
||||
"Jump to previous prompt (built-in)"
|
||||
} else {
|
||||
"Jump to next prompt (built-in)"
|
||||
};
|
||||
out.push(JcodeBinding {
|
||||
field: format!("keybindings.{field} (built-in fallback)"),
|
||||
action: action.to_string(),
|
||||
raw: (*raw).to_string(),
|
||||
chord,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Find conflicts between jcode's configured bindings and the discovered
|
||||
/// machine bindings in `snapshot`.
|
||||
///
|
||||
/// Conflicts are deduplicated per `(jcode field, interceptor chord, source)` so
|
||||
/// a single overlap is reported once even if the snapshot lists the same chord
|
||||
/// multiple times (Ghostty, for example, lists `super+1` and `super+digit_1`).
|
||||
pub fn detect_conflicts(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Vec<Conflict> {
|
||||
// Index discovered bindings by chord for O(1) lookup.
|
||||
let mut by_chord: HashMap<&KeyChord, Vec<&DiscoveredBinding>> = HashMap::new();
|
||||
for b in &snapshot.bindings {
|
||||
by_chord.entry(&b.chord).or_default().push(b);
|
||||
}
|
||||
|
||||
let mut seen: std::collections::HashSet<(String, String, KeySource)> =
|
||||
std::collections::HashSet::new();
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
for jcode in jcode_bindings(cfg) {
|
||||
let Some(interceptors) = by_chord.get(&jcode.chord) else {
|
||||
continue;
|
||||
};
|
||||
for interceptor in interceptors {
|
||||
let dedup_key = (
|
||||
jcode.field.clone(),
|
||||
interceptor.chord.canonical(),
|
||||
interceptor.source,
|
||||
);
|
||||
if !seen.insert(dedup_key) {
|
||||
continue;
|
||||
}
|
||||
conflicts.push(Conflict {
|
||||
jcode: jcode.clone(),
|
||||
interceptor: (*interceptor).clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// A stable signature for a set of conflicts, used to decide whether to re-warn
|
||||
/// the user. Two runs that find the same conflicts (regardless of order)
|
||||
/// produce the same signature; any change (new conflict, resolved conflict,
|
||||
/// rebind) produces a different one.
|
||||
pub fn conflict_signature(conflicts: &[Conflict]) -> String {
|
||||
let mut parts: Vec<String> = conflicts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{}|{}|{}",
|
||||
c.jcode.field,
|
||||
c.jcode.chord.canonical(),
|
||||
c.interceptor.chord.canonical()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
parts.sort();
|
||||
parts.dedup();
|
||||
parts.join(";")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keymap::source::KeySource;
|
||||
|
||||
fn term_binding(canonical_keys: &str, action: &str) -> DiscoveredBinding {
|
||||
DiscoveredBinding {
|
||||
chord: KeyChord::parse(canonical_keys).unwrap(),
|
||||
source: KeySource::Terminal,
|
||||
action: action.to_string(),
|
||||
raw: format!("{canonical_keys}={action}"),
|
||||
tool: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_binding(canonical_keys: &str, action: &str, tool: &str) -> DiscoveredBinding {
|
||||
DiscoveredBinding {
|
||||
chord: KeyChord::parse(canonical_keys).unwrap(),
|
||||
source: KeySource::ExternalApp,
|
||||
action: action.to_string(),
|
||||
raw: canonical_keys.to_string(),
|
||||
tool: tool.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_with(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
|
||||
KeymapSnapshot {
|
||||
version: 1,
|
||||
captured_at: "0".to_string(),
|
||||
os: "macos".to_string(),
|
||||
terminal: "Ghostty".to_string(),
|
||||
terminal_version: String::new(),
|
||||
bindings,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerates_default_bindings() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let binds = jcode_bindings(&cfg);
|
||||
// The defaults include model_switch_next = ctrl+tab.
|
||||
assert!(
|
||||
binds
|
||||
.iter()
|
||||
.any(|b| b.field == "keybindings.model_switch_next"
|
||||
&& b.chord.canonical() == "ctrl+tab"),
|
||||
"expected model_switch_next ctrl+tab in {binds:#?}"
|
||||
);
|
||||
// Workspace nav defaults are alt+h/j/k/l.
|
||||
assert!(
|
||||
binds
|
||||
.iter()
|
||||
.any(|b| b.field == "keybindings.workspace_left" && b.chord.canonical() == "alt+h")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_ghostty_ctrl_tab_conflict() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snapshot = snapshot_with(vec![
|
||||
term_binding("ctrl+tab", "next_tab"),
|
||||
term_binding("ctrl+shift+tab", "previous_tab"),
|
||||
]);
|
||||
let conflicts = detect_conflicts(&cfg, &snapshot);
|
||||
let fields: Vec<&str> = conflicts.iter().map(|c| c.jcode.field.as_str()).collect();
|
||||
assert!(fields.contains(&"keybindings.model_switch_next"));
|
||||
assert!(fields.contains(&"keybindings.model_switch_prev"));
|
||||
let summary = conflicts[0].summary();
|
||||
assert!(summary.contains("model"), "summary was: {summary}");
|
||||
assert!(summary.contains("terminal"), "summary was: {summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_window_manager_prompt_jump_conflict() {
|
||||
// The motivating case: a tiling WM (OmniWM) binds Cmd+J / Cmd+K to window
|
||||
// focus, shadowing jcode's built-in prompt navigation fallback.
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snapshot = snapshot_with(vec![
|
||||
app_binding("cmd+j", "focus.down", "OmniWM"),
|
||||
app_binding("cmd+k", "focus.up", "OmniWM"),
|
||||
]);
|
||||
let conflicts = detect_conflicts(&cfg, &snapshot);
|
||||
assert_eq!(conflicts.len(), 2, "both Cmd+J and Cmd+K should conflict");
|
||||
// The interceptor is attributed to the specific tool, not a generic label.
|
||||
let summary = conflicts[0].summary();
|
||||
assert!(summary.contains("OmniWM"), "summary was: {summary}");
|
||||
assert!(summary.contains("prompt"), "summary was: {summary}");
|
||||
assert!(
|
||||
conflicts
|
||||
.iter()
|
||||
.all(|c| c.jcode.field.contains("built-in fallback")),
|
||||
"prompt-jump fallback fields should be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_conflict_when_chords_differ() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snapshot = snapshot_with(vec![term_binding("cmd+t", "new_tab")]);
|
||||
// jcode has no cmd+t binding by default.
|
||||
assert!(detect_conflicts(&cfg, &snapshot).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_repeated_interceptor_chords() {
|
||||
let cfg = KeybindingsConfig {
|
||||
side_panel_toggle: "cmd+1".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
// Ghostty lists both super+1 and super+digit_1 for goto_tab:1.
|
||||
let snapshot = snapshot_with(vec![
|
||||
term_binding("cmd+1", "goto_tab:1"),
|
||||
term_binding("cmd+1", "goto_tab:1"),
|
||||
]);
|
||||
let conflicts = detect_conflicts(&cfg, &snapshot);
|
||||
assert_eq!(conflicts.len(), 1, "duplicate interceptors should collapse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_binding_is_not_reported() {
|
||||
let cfg = KeybindingsConfig {
|
||||
model_switch_next: "none".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]);
|
||||
let conflicts = detect_conflicts(&cfg, &snapshot);
|
||||
assert!(
|
||||
!conflicts
|
||||
.iter()
|
||||
.any(|c| c.jcode.field == "keybindings.model_switch_next")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_is_order_independent_and_changes_on_diff() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snap_a = snapshot_with(vec![
|
||||
term_binding("ctrl+tab", "next_tab"),
|
||||
term_binding("ctrl+shift+tab", "previous_tab"),
|
||||
]);
|
||||
// Same conflicts, reversed discovery order.
|
||||
let snap_b = snapshot_with(vec![
|
||||
term_binding("ctrl+shift+tab", "previous_tab"),
|
||||
term_binding("ctrl+tab", "next_tab"),
|
||||
]);
|
||||
let sig_a = conflict_signature(&detect_conflicts(&cfg, &snap_a));
|
||||
let sig_b = conflict_signature(&detect_conflicts(&cfg, &snap_b));
|
||||
assert_eq!(sig_a, sig_b, "signature must be order-independent");
|
||||
|
||||
let snap_c = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]);
|
||||
let sig_c = conflict_signature(&detect_conflicts(&cfg, &snap_c));
|
||||
assert_ne!(
|
||||
sig_a, sig_c,
|
||||
"different conflict set => different signature"
|
||||
);
|
||||
|
||||
// No conflicts => empty signature.
|
||||
let clean = snapshot_with(vec![term_binding("cmd+t", "new_tab")]);
|
||||
assert_eq!(conflict_signature(&detect_conflicts(&cfg, &clean)), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Discover global key bindings declared by third-party apps that grab hotkeys
|
||||
//! *before* the terminal (and therefore jcode) ever sees them.
|
||||
//!
|
||||
//! macOS lets window managers and automation tools register system-wide hotkeys.
|
||||
//! When one of those overlaps a key jcode wants (the classic case is a tiling WM
|
||||
//! binding `Cmd+J`/`Cmd+K` to window focus, which shadows jcode's prompt
|
||||
//! navigation), the keystroke never reaches the terminal and the jcode binding
|
||||
//! silently does nothing. The terminal/macOS scanners cannot see these, so we
|
||||
//! read the relevant app config files directly.
|
||||
//!
|
||||
//! Each app has its own config grammar, so there is one pure parser per app
|
||||
//! (`parse_*`) plus a thin reader (`read_*`) that locates and loads the file.
|
||||
//! Parsers are unit-tested without touching the machine.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::source::{DiscoveredBinding, KeySource};
|
||||
|
||||
/// A modifier mask accumulated while parsing a chord, kept separate from the key
|
||||
/// token so apps that express the "hyper" key as a bundle of modifiers can be
|
||||
/// expanded uniformly.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Mods {
|
||||
cmd: bool,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
fn or(self, other: Mods) -> Mods {
|
||||
Mods {
|
||||
cmd: self.cmd || other.cmd,
|
||||
ctrl: self.ctrl || other.ctrl,
|
||||
alt: self.alt || other.alt,
|
||||
shift: self.shift || other.shift,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single modifier token to `mods`, expanding `hyper` via `hyper_mods`.
|
||||
/// Returns `true` if the token was a recognized modifier, `false` if it should
|
||||
/// be treated as the primary key.
|
||||
fn apply_modifier(token: &str, mods: &mut Mods, hyper_mods: Mods) -> bool {
|
||||
match token.trim().to_ascii_lowercase().as_str() {
|
||||
"cmd" | "command" | "super" | "win" | "windows" => mods.cmd = true,
|
||||
"ctrl" | "control" => mods.ctrl = true,
|
||||
"alt" | "opt" | "option" => mods.alt = true,
|
||||
"shift" => mods.shift = true,
|
||||
// "hyper" is an app-defined alias for some bundle of real modifiers.
|
||||
"hyper" => *mods = mods.or(hyper_mods),
|
||||
// "fn" is not representable as a jcode modifier; ignore it so the rest of
|
||||
// the chord still parses.
|
||||
"fn" | "function" => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Parse a "Hyper" definition (e.g. OmniWM's `hyperTrigger = "Option"`, or a
|
||||
/// compound like "Cmd+Ctrl+Alt+Shift") into the modifier bundle it stands for.
|
||||
fn parse_hyper_mods(spec: &str) -> Mods {
|
||||
let mut mods = Mods::default();
|
||||
for token in spec.split(['+', '-']) {
|
||||
// Recurse-safe: "hyper" inside a hyper definition is meaningless, so pass
|
||||
// an empty bundle.
|
||||
apply_modifier(token, &mut mods, Mods::default());
|
||||
}
|
||||
mods
|
||||
}
|
||||
|
||||
/// Build a chord from a list of tokens (modifiers + one key), where modifiers and
|
||||
/// the key are already separated out. `hyper_mods` expands any `hyper` token.
|
||||
/// Returns `None` if no primary key token was found.
|
||||
fn chord_from_tokens<'a>(
|
||||
tokens: impl IntoIterator<Item = &'a str>,
|
||||
hyper_mods: Mods,
|
||||
) -> Option<KeyChord> {
|
||||
let mut mods = Mods::default();
|
||||
let mut key: Option<String> = None;
|
||||
for token in tokens {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !apply_modifier(token, &mut mods, hyper_mods) {
|
||||
// Last non-modifier token wins as the key.
|
||||
key = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
let key = key?;
|
||||
Some(KeyChord::new(
|
||||
mods.cmd, mods.ctrl, mods.alt, mods.shift, &key,
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OmniWM (~/.config/omniwm/settings.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an OmniWM `settings.toml` into discovered bindings. OmniWM stores
|
||||
/// hotkeys as an array of tables:
|
||||
///
|
||||
/// ```toml
|
||||
/// hyperTrigger = "Option"
|
||||
///
|
||||
/// [[hotkeys]]
|
||||
/// binding = "Command+J"
|
||||
/// id = "focus.down"
|
||||
/// ```
|
||||
///
|
||||
/// `binding = "Unassigned"` entries are skipped. The `hyperTrigger` value (or a
|
||||
/// default of Option) expands any `Hyper+...` binding.
|
||||
pub fn parse_omniwm(text: &str) -> Vec<DiscoveredBinding> {
|
||||
let Ok(value) = text.parse::<toml::Value>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Hyper expands to the configured trigger; OmniWM defaults to Option.
|
||||
let hyper_mods = value
|
||||
.get("general")
|
||||
.and_then(|g| g.get("hyperTrigger"))
|
||||
.or_else(|| value.get("hyperTrigger"))
|
||||
.and_then(|h| h.as_str())
|
||||
.map(parse_hyper_mods)
|
||||
.unwrap_or(Mods {
|
||||
alt: true,
|
||||
..Mods::default()
|
||||
});
|
||||
|
||||
let Some(hotkeys) = value.get("hotkeys").and_then(|h| h.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for hk in hotkeys {
|
||||
let Some(binding) = hk.get("binding").and_then(|b| b.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if binding.trim().eq_ignore_ascii_case("unassigned") || binding.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let action = hk
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if let Some(chord) = chord_from_tokens(binding.split(['+', '-']), hyper_mods) {
|
||||
out.push(DiscoveredBinding {
|
||||
chord,
|
||||
source: KeySource::ExternalApp,
|
||||
action,
|
||||
raw: binding.to_string(),
|
||||
tool: "OmniWM".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read and parse OmniWM's config, if present.
|
||||
pub fn read_omniwm() -> Vec<DiscoveredBinding> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let path = home.join(".config/omniwm/settings.toml");
|
||||
read_to_string(&path)
|
||||
.map(|t| parse_omniwm(&t))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AeroSpace (~/.aerospace.toml or ~/.config/aerospace/aerospace.toml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an AeroSpace config into discovered bindings. AeroSpace declares
|
||||
/// bindings under `[mode.<name>.binding]` tables where the key is a chord like
|
||||
/// `alt-h` and the value is the command:
|
||||
///
|
||||
/// ```toml
|
||||
/// [mode.main.binding]
|
||||
/// alt-h = 'focus left'
|
||||
/// cmd-shift-l = ['move right', 'mode main']
|
||||
/// ```
|
||||
pub fn parse_aerospace(text: &str) -> Vec<DiscoveredBinding> {
|
||||
let Ok(value) = text.parse::<toml::Value>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(modes) = value.get("mode").and_then(|m| m.as_table()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for mode in modes.values() {
|
||||
let Some(bindings) = mode.get("binding").and_then(|b| b.as_table()) else {
|
||||
continue;
|
||||
};
|
||||
for (chord_str, action_val) in bindings {
|
||||
// AeroSpace separates modifiers and key with '-'.
|
||||
let Some(chord) = chord_from_tokens(chord_str.split('-'), Mods::default()) else {
|
||||
continue;
|
||||
};
|
||||
let action = aerospace_action_label(action_val);
|
||||
out.push(DiscoveredBinding {
|
||||
chord,
|
||||
source: KeySource::ExternalApp,
|
||||
action,
|
||||
raw: chord_str.clone(),
|
||||
tool: "AeroSpace".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn aerospace_action_label(value: &toml::Value) -> String {
|
||||
match value {
|
||||
toml::Value::String(s) => s.clone(),
|
||||
toml::Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and parse AeroSpace's config from either supported location.
|
||||
pub fn read_aerospace() -> Vec<DiscoveredBinding> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
for rel in [".aerospace.toml", ".config/aerospace/aerospace.toml"] {
|
||||
let path = home.join(rel);
|
||||
if let Some(text) = read_to_string(&path) {
|
||||
return parse_aerospace(&text);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// skhd (~/.config/skhd/skhdrc or ~/.skhdrc)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an skhd config into discovered bindings. skhd lines look like:
|
||||
///
|
||||
/// ```text
|
||||
/// cmd - h : yabai -m window --focus west
|
||||
/// cmd + shift - 0x2C : echo hi
|
||||
/// # comment
|
||||
/// :: mode @ : ... # mode declaration, ignored
|
||||
/// ```
|
||||
///
|
||||
/// The activation (left of the first `:`) is `mods - key`, where modifiers are
|
||||
/// joined with `+` and separated from the key by `-`.
|
||||
pub fn parse_skhd(text: &str) -> Vec<DiscoveredBinding> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with("::") {
|
||||
continue;
|
||||
}
|
||||
// Activation is everything before the first ':' that starts the command.
|
||||
let Some(colon) = line.find(':') else {
|
||||
continue;
|
||||
};
|
||||
let activation = line[..colon].trim();
|
||||
let action = line[colon + 1..].trim();
|
||||
if activation.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Strip an optional leading mode list ("mode_name <") if present.
|
||||
let activation = activation.rsplit('<').next().unwrap_or(activation).trim();
|
||||
|
||||
// Split modifiers from key on the first '-'. Modifiers use '+'.
|
||||
let (mods_part, key_part) = match activation.split_once('-') {
|
||||
Some((m, k)) => (m, k),
|
||||
None => ("", activation),
|
||||
};
|
||||
let tokens = mods_part
|
||||
.split('+')
|
||||
.chain(std::iter::once(key_part))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(chord) = chord_from_tokens(tokens, Mods::default()) {
|
||||
out.push(DiscoveredBinding {
|
||||
chord,
|
||||
source: KeySource::ExternalApp,
|
||||
action: action.to_string(),
|
||||
raw: activation.to_string(),
|
||||
tool: "skhd".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read and parse skhd's config from either supported location.
|
||||
pub fn read_skhd() -> Vec<DiscoveredBinding> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
for rel in [".config/skhd/skhdrc", ".skhdrc"] {
|
||||
let path = home.join(rel);
|
||||
if let Some(text) = read_to_string(&path) {
|
||||
return parse_skhd(&text);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read every supported external app's bindings on this machine.
|
||||
pub fn read_external_bindings() -> Vec<DiscoveredBinding> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(read_omniwm());
|
||||
out.extend(read_aerospace());
|
||||
out.extend(read_skhd());
|
||||
out
|
||||
}
|
||||
|
||||
fn read_to_string(path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// Exposed for tests/diagnostics: the config paths we look for, relative to the
|
||||
/// home directory.
|
||||
pub fn external_config_paths() -> Vec<PathBuf> {
|
||||
[
|
||||
".config/omniwm/settings.toml",
|
||||
".aerospace.toml",
|
||||
".config/aerospace/aerospace.toml",
|
||||
".config/skhd/skhdrc",
|
||||
".skhdrc",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn omniwm_cmd_jk_focus_bindings() {
|
||||
let cfg = r#"
|
||||
[general]
|
||||
hyperTrigger = "Option"
|
||||
|
||||
[[hotkeys]]
|
||||
binding = "Command+J"
|
||||
id = "focus.down"
|
||||
|
||||
[[hotkeys]]
|
||||
binding = "Command+K"
|
||||
id = "focus.up"
|
||||
|
||||
[[hotkeys]]
|
||||
binding = "Unassigned"
|
||||
id = "focusPrevious"
|
||||
|
||||
[[hotkeys]]
|
||||
binding = "Hyper+1"
|
||||
id = "switchWorkspace.0"
|
||||
"#;
|
||||
let binds = parse_omniwm(cfg);
|
||||
// Cmd+J, Cmd+K, and Hyper(=Option=alt)+1; Unassigned is skipped.
|
||||
assert_eq!(binds.len(), 3);
|
||||
let jk: Vec<_> = binds
|
||||
.iter()
|
||||
.filter(|b| b.chord.canonical() == "cmd+j" || b.chord.canonical() == "cmd+k")
|
||||
.collect();
|
||||
assert_eq!(jk.len(), 2);
|
||||
for b in &jk {
|
||||
assert_eq!(b.source, KeySource::ExternalApp);
|
||||
assert_eq!(b.tool, "OmniWM");
|
||||
}
|
||||
// Hyper+1 expands to alt+1 (Option trigger).
|
||||
assert!(binds.iter().any(|b| b.chord.canonical() == "alt+1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omniwm_hyper_defaults_to_option_when_unset() {
|
||||
let cfg = r#"
|
||||
[[hotkeys]]
|
||||
binding = "Hyper+2"
|
||||
id = "switchWorkspace.1"
|
||||
"#;
|
||||
let binds = parse_omniwm(cfg);
|
||||
assert_eq!(binds.len(), 1);
|
||||
assert_eq!(binds[0].chord.canonical(), "alt+2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omniwm_cmd_shift_move_bindings() {
|
||||
let cfg = r#"
|
||||
[[hotkeys]]
|
||||
binding = "Command+Shift+K"
|
||||
id = "move.up"
|
||||
"#;
|
||||
let binds = parse_omniwm(cfg);
|
||||
assert_eq!(binds.len(), 1);
|
||||
assert_eq!(binds[0].chord.canonical(), "cmd+shift+k");
|
||||
assert_eq!(binds[0].action, "move.up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aerospace_binding_section() {
|
||||
let cfg = r#"
|
||||
[mode.main.binding]
|
||||
alt-h = 'focus left'
|
||||
cmd-shift-l = ['move right', 'mode main']
|
||||
"#;
|
||||
let binds = parse_aerospace(cfg);
|
||||
assert_eq!(binds.len(), 2);
|
||||
let h = binds
|
||||
.iter()
|
||||
.find(|b| b.chord.canonical() == "alt+h")
|
||||
.unwrap();
|
||||
assert_eq!(h.action, "focus left");
|
||||
assert_eq!(h.tool, "AeroSpace");
|
||||
let l = binds
|
||||
.iter()
|
||||
.find(|b| b.chord.canonical() == "cmd+shift+l")
|
||||
.unwrap();
|
||||
assert_eq!(l.action, "move right; mode main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skhd_basic_lines() {
|
||||
let cfg = "\
|
||||
# comment\n\
|
||||
cmd - h : yabai -m window --focus west\n\
|
||||
cmd + shift - j : yabai -m window --swap south\n\
|
||||
:: default : echo mode\n\
|
||||
";
|
||||
let binds = parse_skhd(cfg);
|
||||
assert_eq!(binds.len(), 2);
|
||||
assert_eq!(binds[0].chord.canonical(), "cmd+h");
|
||||
assert_eq!(binds[0].tool, "skhd");
|
||||
assert_eq!(binds[1].chord.canonical(), "cmd+shift+j");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skhd_keyless_modifier_only_is_skipped() {
|
||||
// A bare modifier with no key cannot form a chord.
|
||||
let binds = parse_skhd("cmd - : noop\n");
|
||||
assert!(binds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_toml_yields_nothing() {
|
||||
assert!(parse_omniwm("this is = = not toml").is_empty());
|
||||
assert!(parse_aerospace("[[[bad").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Decode macOS system keyboard shortcuts from `com.apple.symbolichotkeys`.
|
||||
//!
|
||||
//! macOS stores global shortcuts (Spotlight, Mission Control, screenshots,
|
||||
//! input-source switching, etc.) under the `AppleSymbolicHotKeys` key of the
|
||||
//! `com.apple.symbolichotkeys` preference domain. Each entry is:
|
||||
//!
|
||||
//! ```text
|
||||
//! <id> = { enabled = 0/1; value = { parameters = (ascii, keycode, modmask); ... }; }
|
||||
//! ```
|
||||
//!
|
||||
//! We read it with `defaults export ... | plutil -convert json` and decode the
|
||||
//! `[ascii, keycode, modmask]` triple into a [`KeyChord`]. The pure decoding
|
||||
//! logic lives here (and is unit-tested); the subprocess plumbing is isolated in
|
||||
//! [`read_symbolic_hotkeys`].
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::source::{DiscoveredBinding, KeySource};
|
||||
|
||||
/// NSEvent modifier flag bits used in the symbolic-hotkeys `modmask`.
|
||||
const NS_SHIFT: u64 = 0x0002_0000;
|
||||
const NS_CONTROL: u64 = 0x0004_0000;
|
||||
const NS_OPTION: u64 = 0x0008_0000;
|
||||
const NS_COMMAND: u64 = 0x0010_0000;
|
||||
|
||||
/// Human-readable names for well-known symbolic-hotkey IDs. Only used to make
|
||||
/// the snapshot and warnings legible; unknown IDs still get decoded.
|
||||
fn action_name(id: i64) -> String {
|
||||
let name = match id {
|
||||
32 => "Mission Control",
|
||||
33 => "Mission Control: Application windows",
|
||||
36 => "Show Launchpad",
|
||||
60 => "Select previous input source",
|
||||
61 => "Select next input source",
|
||||
64 => "Spotlight: Show search",
|
||||
65 => "Spotlight: Show Finder search window",
|
||||
79 => "Move left a space",
|
||||
81 => "Move right a space",
|
||||
// Screenshots
|
||||
28 => "Screenshot: Save picture of screen",
|
||||
29 => "Screenshot: Copy picture of screen",
|
||||
30 => "Screenshot: Save picture of selected area",
|
||||
31 => "Screenshot: Copy picture of selected area",
|
||||
184 => "Screenshot and recording options",
|
||||
// Misc
|
||||
162 => "Show Notification Center",
|
||||
163 => "Toggle Do Not Disturb",
|
||||
175 => "Dictation",
|
||||
_ => "",
|
||||
};
|
||||
if name.is_empty() {
|
||||
format!("macOS hotkey #{id}")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a macOS virtual keycode to a normalized key token. Covers the common
|
||||
/// keys that appear in default system shortcuts. Returns `None` for keys we do
|
||||
/// not have a stable mapping for (we then fall back to the ASCII parameter).
|
||||
fn keycode_to_token(keycode: i64) -> Option<&'static str> {
|
||||
Some(match keycode {
|
||||
0 => "a",
|
||||
1 => "s",
|
||||
2 => "d",
|
||||
3 => "f",
|
||||
4 => "h",
|
||||
5 => "g",
|
||||
6 => "z",
|
||||
7 => "x",
|
||||
8 => "c",
|
||||
9 => "v",
|
||||
11 => "b",
|
||||
12 => "q",
|
||||
13 => "w",
|
||||
14 => "e",
|
||||
15 => "r",
|
||||
16 => "y",
|
||||
17 => "t",
|
||||
18 => "1",
|
||||
19 => "2",
|
||||
20 => "3",
|
||||
21 => "4",
|
||||
22 => "6",
|
||||
23 => "5",
|
||||
24 => "=",
|
||||
25 => "9",
|
||||
26 => "7",
|
||||
27 => "-",
|
||||
28 => "8",
|
||||
29 => "0",
|
||||
30 => "]",
|
||||
31 => "o",
|
||||
32 => "u",
|
||||
33 => "[",
|
||||
34 => "i",
|
||||
35 => "p",
|
||||
37 => "l",
|
||||
38 => "j",
|
||||
39 => "'",
|
||||
40 => "k",
|
||||
41 => ";",
|
||||
42 => "\\",
|
||||
43 => ",",
|
||||
44 => "/",
|
||||
45 => "n",
|
||||
46 => "m",
|
||||
47 => ".",
|
||||
50 => "`",
|
||||
36 => "enter",
|
||||
48 => "tab",
|
||||
49 => "space",
|
||||
51 => "backspace",
|
||||
53 => "esc",
|
||||
// Function keys
|
||||
122 => "f1",
|
||||
120 => "f2",
|
||||
99 => "f3",
|
||||
118 => "f4",
|
||||
96 => "f5",
|
||||
97 => "f6",
|
||||
98 => "f7",
|
||||
100 => "f8",
|
||||
101 => "f9",
|
||||
109 => "f10",
|
||||
103 => "f11",
|
||||
111 => "f12",
|
||||
// Arrows
|
||||
123 => "left",
|
||||
124 => "right",
|
||||
125 => "down",
|
||||
126 => "up",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a single `[ascii, keycode, modmask]` parameter triple into a chord.
|
||||
///
|
||||
/// `ascii == 65535` (and `keycode == 65535`) means "no key assigned", in which
|
||||
/// case we return `None`. Prefer the virtual keycode for the key token; fall
|
||||
/// back to the ASCII value when the keycode is unknown.
|
||||
pub fn decode_parameters(ascii: i64, keycode: i64, modmask: i64) -> Option<KeyChord> {
|
||||
if keycode == 65535 && ascii == 65535 {
|
||||
return None;
|
||||
}
|
||||
let mask = modmask as u64;
|
||||
let cmd = mask & NS_COMMAND != 0;
|
||||
let ctrl = mask & NS_CONTROL != 0;
|
||||
let alt = mask & NS_OPTION != 0;
|
||||
let shift = mask & NS_SHIFT != 0;
|
||||
|
||||
let key = if let Some(tok) = keycode_to_token(keycode) {
|
||||
tok.to_string()
|
||||
} else if ascii > 0 && ascii < 0x10_FFFF && ascii != 65535 {
|
||||
// Fall back to the literal character from the ascii parameter.
|
||||
char::from_u32(ascii as u32)
|
||||
.filter(|c| !c.is_control())
|
||||
.map(|c| c.to_ascii_lowercase().to_string())?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(KeyChord::new(cmd, ctrl, alt, shift, &key))
|
||||
}
|
||||
|
||||
/// One raw symbolic-hotkey entry, as decoded from JSON.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawHotkey {
|
||||
pub id: i64,
|
||||
pub enabled: bool,
|
||||
pub parameters: Option<(i64, i64, i64)>,
|
||||
}
|
||||
|
||||
/// Parse the JSON produced by `plutil -convert json` of the symbolic-hotkeys
|
||||
/// domain into raw hotkey entries. Tolerates the parameters being encoded as
|
||||
/// either JSON numbers or numeric strings (macOS does both).
|
||||
pub fn parse_symbolic_hotkeys_json(json: &str) -> Vec<RawHotkey> {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(map) = value
|
||||
.get("AppleSymbolicHotKeys")
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (id_str, entry) in map {
|
||||
let Ok(id) = id_str.parse::<i64>() else {
|
||||
continue;
|
||||
};
|
||||
let enabled = match entry.get("enabled") {
|
||||
Some(serde_json::Value::Bool(b)) => *b,
|
||||
Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0) != 0,
|
||||
_ => false,
|
||||
};
|
||||
let parameters = entry
|
||||
.get("value")
|
||||
.and_then(|v| v.get("parameters"))
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|arr| {
|
||||
let nums: Vec<i64> = arr.iter().filter_map(json_as_i64).collect();
|
||||
if nums.len() >= 3 {
|
||||
Some((nums[0], nums[1], nums[2]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
out.push(RawHotkey {
|
||||
id,
|
||||
enabled,
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn json_as_i64(v: &serde_json::Value) -> Option<i64> {
|
||||
match v {
|
||||
serde_json::Value::Number(n) => n.as_i64(),
|
||||
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn raw hotkey entries into discovered bindings, skipping ones that are
|
||||
/// disabled or have no assignable key.
|
||||
pub fn hotkeys_to_bindings(raw: &[RawHotkey]) -> Vec<DiscoveredBinding> {
|
||||
let mut out = Vec::new();
|
||||
for hk in raw {
|
||||
if !hk.enabled {
|
||||
continue;
|
||||
}
|
||||
let Some((ascii, keycode, modmask)) = hk.parameters else {
|
||||
continue;
|
||||
};
|
||||
let Some(chord) = decode_parameters(ascii, keycode, modmask) else {
|
||||
continue;
|
||||
};
|
||||
out.push(DiscoveredBinding {
|
||||
chord,
|
||||
source: KeySource::MacosSystem,
|
||||
action: action_name(hk.id),
|
||||
raw: format!("symbolichotkey #{}", hk.id),
|
||||
tool: String::new(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read and decode the live macOS symbolic hotkeys via `defaults` + `plutil`.
|
||||
/// Returns an empty vec on any failure (missing tools, non-macOS, parse error).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn read_symbolic_hotkeys() -> Vec<DiscoveredBinding> {
|
||||
use std::process::Command;
|
||||
|
||||
let export = Command::new("/usr/bin/defaults")
|
||||
.args(["export", "com.apple.symbolichotkeys", "-"])
|
||||
.output();
|
||||
let Ok(export) = export else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !export.status.success() || export.stdout.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Pipe the exported plist through plutil to get JSON.
|
||||
let mut child = match Command::new("/usr/bin/plutil")
|
||||
.args(["-convert", "json", "-o", "-", "-"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use std::io::Write;
|
||||
let _ = stdin.write_all(&export.stdout);
|
||||
// stdin dropped here, closing the pipe.
|
||||
}
|
||||
|
||||
let Ok(output) = child.wait_with_output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Vec::new();
|
||||
}
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
let raw = parse_symbolic_hotkeys_json(&json);
|
||||
hotkeys_to_bindings(&raw)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn read_symbolic_hotkeys() -> Vec<DiscoveredBinding> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_spotlight_cmd_space() {
|
||||
// [32 (space ascii), 49 (space keycode), 0x100000 (cmd)]
|
||||
let chord = decode_parameters(32, 49, 1_048_576).unwrap();
|
||||
assert_eq!(chord.canonical(), "cmd+space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_input_source_ctrl_space() {
|
||||
// Select previous input source: [32, 49, 0x40000 (ctrl)]
|
||||
let chord = decode_parameters(32, 49, 262_144).unwrap();
|
||||
assert_eq!(chord.canonical(), "ctrl+space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassigned_returns_none() {
|
||||
assert!(decode_parameters(65535, 65535, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_modifiers_decode() {
|
||||
// ctrl+option+cmd = 0x40000 | 0x80000 | 0x100000 = 0x1C0000
|
||||
let chord = decode_parameters(0, 40, 0x1C_0000).unwrap();
|
||||
assert!(chord.cmd && chord.ctrl && chord.alt && !chord.shift);
|
||||
assert_eq!(chord.key, "k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_json_with_string_and_numeric_params() {
|
||||
let json = r#"{
|
||||
"AppleSymbolicHotKeys": {
|
||||
"64": {"enabled": 1, "value": {"parameters": ["32", "49", "1048576"]}},
|
||||
"60": {"enabled": false, "value": {"parameters": [32, 49, 262144]}},
|
||||
"999": {"enabled": 1}
|
||||
}
|
||||
}"#;
|
||||
let raw = parse_symbolic_hotkeys_json(json);
|
||||
assert_eq!(raw.len(), 3);
|
||||
let bindings = hotkeys_to_bindings(&raw);
|
||||
// Only #64 is enabled with params.
|
||||
assert_eq!(bindings.len(), 1);
|
||||
assert_eq!(bindings[0].chord.canonical(), "cmd+space");
|
||||
assert!(bindings[0].action.contains("Spotlight"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Keymap discovery: snapshot the key bindings that exist on the machine
|
||||
//! (macOS system shortcuts + terminal emulator bindings) so jcode can detect
|
||||
//! when one of them intercepts a key jcode wants to use.
|
||||
//!
|
||||
//! This module is the data layer. It:
|
||||
//! 1. discovers bindings from each source ([`macos_hotkeys`], [`terminal`]),
|
||||
//! 2. normalizes them to [`KeyChord`]s ([`chord`]),
|
||||
//! 3. records them in a durable JSON snapshot ([`KeymapSnapshot`]).
|
||||
//!
|
||||
//! Conflict detection against jcode's own bindings is built on top of this in a
|
||||
//! later layer.
|
||||
|
||||
pub mod chord;
|
||||
pub mod conflicts;
|
||||
pub mod external;
|
||||
pub mod macos_hotkeys;
|
||||
pub mod report;
|
||||
pub mod source;
|
||||
pub mod terminal;
|
||||
|
||||
pub use chord::KeyChord;
|
||||
pub use conflicts::{Conflict, JcodeBinding, conflict_signature, detect_conflicts, jcode_bindings};
|
||||
pub use report::{render_report, render_status_line};
|
||||
pub use source::{DiscoveredBinding, KeySource};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema version for the on-disk snapshot. Bump when the format changes.
|
||||
const SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
/// A durable record of the key bindings discovered on this machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeymapSnapshot {
|
||||
pub version: u32,
|
||||
/// RFC3339-ish timestamp of when the snapshot was taken.
|
||||
pub captured_at: String,
|
||||
pub os: String,
|
||||
/// Detected terminal label (e.g. "Ghostty"), best-effort.
|
||||
pub terminal: String,
|
||||
/// Terminal version string if known.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub terminal_version: String,
|
||||
/// All discovered bindings, across every source.
|
||||
pub bindings: Vec<DiscoveredBinding>,
|
||||
}
|
||||
|
||||
impl KeymapSnapshot {
|
||||
/// Bindings originating from a particular source.
|
||||
pub fn from_source(&self, source: KeySource) -> impl Iterator<Item = &DiscoveredBinding> {
|
||||
self.bindings.iter().filter(move |b| b.source == source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the terminal name in a cross-platform, dependency-light way. On macOS
|
||||
/// we mirror the detection used elsewhere; on other platforms we fall back to
|
||||
/// `TERM_PROGRAM`/`TERM`.
|
||||
fn detect_terminal_label() -> String {
|
||||
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|
||||
|| term_program.eq_ignore_ascii_case("ghostty")
|
||||
|| term.to_lowercase().contains("ghostty")
|
||||
{
|
||||
return "Ghostty".to_string();
|
||||
}
|
||||
match term_program.to_lowercase().as_str() {
|
||||
"iterm.app" => "iTerm2".to_string(),
|
||||
"apple_terminal" => "Terminal.app".to_string(),
|
||||
"wezterm" => "WezTerm".to_string(),
|
||||
"vscode" => "VS Code terminal".to_string(),
|
||||
"" => {
|
||||
if term.to_lowercase().contains("alacritty") {
|
||||
"Alacritty".to_string()
|
||||
} else if term.to_lowercase().contains("kitty") {
|
||||
"kitty".to_string()
|
||||
} else {
|
||||
term
|
||||
}
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_timestamp() -> String {
|
||||
// Avoid pulling in chrono here; seconds since epoch is enough to detect a
|
||||
// stale snapshot, and we render it back as a readable value at display time.
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Collect all discovered bindings on the current machine. This shells out to
|
||||
/// the platform tools (`defaults`/`plutil`, `ghostty +list-keybinds`) and so
|
||||
/// should be called off the hot path / at startup, not per-frame.
|
||||
pub fn collect_snapshot() -> KeymapSnapshot {
|
||||
let mut bindings = Vec::new();
|
||||
bindings.extend(macos_hotkeys::read_symbolic_hotkeys());
|
||||
bindings.extend(terminal::read_ghostty_keybinds());
|
||||
bindings.extend(external::read_external_bindings());
|
||||
|
||||
KeymapSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
captured_at: now_timestamp(),
|
||||
os: std::env::consts::OS.to_string(),
|
||||
terminal: detect_terminal_label(),
|
||||
terminal_version: std::env::var("TERM_PROGRAM_VERSION").unwrap_or_default(),
|
||||
bindings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the on-disk keymap snapshot.
|
||||
pub fn snapshot_path() -> anyhow::Result<std::path::PathBuf> {
|
||||
Ok(jcode_storage::jcode_dir()?.join("keymap-snapshot.json"))
|
||||
}
|
||||
|
||||
/// Collect a fresh snapshot and persist it to `~/.jcode/keymap-snapshot.json`.
|
||||
/// Returns the snapshot regardless of whether the write succeeded.
|
||||
pub fn refresh_and_save() -> KeymapSnapshot {
|
||||
let snapshot = collect_snapshot();
|
||||
if let Ok(path) = snapshot_path()
|
||||
// Regeneratable cache (refreshed at least daily): a power-loss losing the
|
||||
// newest copy just means one more refresh on the next launch, so skip the
|
||||
// ~8ms macOS `F_FULLFSYNC` and use the atomic-rename fast write.
|
||||
&& let Err(err) = jcode_storage::write_json_fast(&path, &snapshot)
|
||||
{
|
||||
jcode_logging::warn(&format!("keymap snapshot write failed: {err}"));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Load the last persisted snapshot, if any.
|
||||
pub fn load_snapshot() -> Option<KeymapSnapshot> {
|
||||
let path = snapshot_path().ok()?;
|
||||
jcode_storage::read_json(&path).ok()
|
||||
}
|
||||
|
||||
/// Maximum age (seconds) before a cached snapshot is considered stale and
|
||||
/// refreshed. One day balances freshness against the cost of shelling out.
|
||||
const SNAPSHOT_MAX_AGE_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
/// Return a usable snapshot, refreshing from the machine only when there is no
|
||||
/// cached snapshot or the cached one is older than [`SNAPSHOT_MAX_AGE_SECS`].
|
||||
/// This is the entry point intended for startup: cheap on the common path,
|
||||
/// self-healing when stale.
|
||||
pub fn snapshot_cached_or_refresh() -> KeymapSnapshot {
|
||||
if let Some(existing) = load_snapshot()
|
||||
&& existing.version == SNAPSHOT_VERSION
|
||||
&& !snapshot_is_stale(&existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
refresh_and_save()
|
||||
}
|
||||
|
||||
fn snapshot_is_stale(snapshot: &KeymapSnapshot) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let Ok(captured) = snapshot.captured_at.parse::<u64>() else {
|
||||
return true;
|
||||
};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(captured) > SNAPSHOT_MAX_AGE_SECS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_roundtrips_through_json() {
|
||||
let snap = KeymapSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
captured_at: "123".to_string(),
|
||||
os: "macos".to_string(),
|
||||
terminal: "Ghostty".to_string(),
|
||||
terminal_version: "1.3.1".to_string(),
|
||||
bindings: vec![DiscoveredBinding {
|
||||
chord: KeyChord::new(true, false, false, false, "k"),
|
||||
source: KeySource::Terminal,
|
||||
action: "clear_screen".to_string(),
|
||||
raw: "super+k=clear_screen".to_string(),
|
||||
tool: String::new(),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&snap).unwrap();
|
||||
let back: KeymapSnapshot = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.bindings.len(), 1);
|
||||
assert_eq!(back.bindings[0].chord.canonical(), "cmd+k");
|
||||
assert_eq!(
|
||||
back.from_source(KeySource::Terminal).count(),
|
||||
1,
|
||||
"should find the terminal binding"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Human-readable rendering of the keymap snapshot and detected conflicts,
|
||||
//! shared by the `/keys` command and the startup conflict hint.
|
||||
|
||||
use jcode_config_types::KeybindingsConfig;
|
||||
|
||||
use super::KeymapSnapshot;
|
||||
use super::conflicts::{Conflict, detect_conflicts};
|
||||
use super::source::KeySource;
|
||||
|
||||
/// Render a full diagnostic report: detected terminal, discovered binding
|
||||
/// counts, and any conflicts with jcode's configured bindings.
|
||||
pub fn render_report(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Keymap diagnostics\n");
|
||||
out.push_str(&format!(
|
||||
"Terminal: {}{}\n",
|
||||
snapshot.terminal,
|
||||
if snapshot.terminal_version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", snapshot.terminal_version)
|
||||
}
|
||||
));
|
||||
out.push_str(&format!("OS: {}\n", snapshot.os));
|
||||
|
||||
let term_count = snapshot.from_source(KeySource::Terminal).count();
|
||||
let sys_count = snapshot.from_source(KeySource::MacosSystem).count();
|
||||
let app_count = snapshot.from_source(KeySource::ExternalApp).count();
|
||||
out.push_str(&format!(
|
||||
"Discovered bindings: {term_count} terminal, {sys_count} macOS system, {app_count} app\n",
|
||||
));
|
||||
|
||||
if app_count > 0 {
|
||||
let mut tools: Vec<&str> = snapshot
|
||||
.from_source(KeySource::ExternalApp)
|
||||
.map(|b| b.tool.as_str())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
tools.dedup();
|
||||
if !tools.is_empty() {
|
||||
out.push_str(&format!("Apps scanned: {}\n", tools.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
if term_count == 0 && sys_count == 0 && app_count == 0 {
|
||||
out.push_str(
|
||||
"\nNo machine bindings were discovered. jcode can read Ghostty bindings, macOS\n\
|
||||
system shortcuts, and a few window managers (OmniWM, AeroSpace, skhd); other\n\
|
||||
terminals and tools are not yet inspected, so conflicts there will not be\n\
|
||||
detected.\n",
|
||||
);
|
||||
}
|
||||
|
||||
let conflicts = detect_conflicts(cfg, snapshot);
|
||||
out.push('\n');
|
||||
if conflicts.is_empty() {
|
||||
out.push_str("No conflicts found between your jcode keybindings and the machine.\n");
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"{} potential conflict{} found:\n\n",
|
||||
conflicts.len(),
|
||||
if conflicts.len() == 1 { "" } else { "s" }
|
||||
));
|
||||
for c in &conflicts {
|
||||
out.push_str(&render_conflict_block(c));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(
|
||||
"These keys may be captured by your terminal, macOS, or another app (window\n\
|
||||
manager, launcher) before jcode sees them.\n\
|
||||
To fix: rebind the jcode action in ~/.jcode/config.toml under [keybindings],\n\
|
||||
or change the conflicting shortcut in the other app's settings.\n",
|
||||
);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn render_conflict_block(c: &Conflict) -> String {
|
||||
let interceptor_desc = match c.interceptor.source {
|
||||
KeySource::MacosSystem => format!("macOS: {}", c.interceptor.action),
|
||||
KeySource::Terminal => {
|
||||
if c.interceptor.action.is_empty() {
|
||||
"terminal action".to_string()
|
||||
} else {
|
||||
format!("terminal: {}", c.interceptor.action)
|
||||
}
|
||||
}
|
||||
KeySource::ExternalApp => {
|
||||
let tool = if c.interceptor.tool.is_empty() {
|
||||
"app"
|
||||
} else {
|
||||
c.interceptor.tool.as_str()
|
||||
};
|
||||
if c.interceptor.action.is_empty() {
|
||||
tool.to_string()
|
||||
} else {
|
||||
format!("{tool}: {}", c.interceptor.action)
|
||||
}
|
||||
}
|
||||
};
|
||||
format!(
|
||||
" ⚠ {key}\n jcode: {action} ({field} = \"{raw}\")\n taken by {interceptor}\n",
|
||||
key = c.jcode.chord.display(),
|
||||
action = c.jcode.action,
|
||||
field = c.jcode.field,
|
||||
raw = c.jcode.raw,
|
||||
interceptor = interceptor_desc,
|
||||
)
|
||||
}
|
||||
|
||||
/// A compact one-line status string suitable for a startup notice, or `None`
|
||||
/// when there are no conflicts.
|
||||
pub fn render_status_line(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Option<String> {
|
||||
let conflicts = detect_conflicts(cfg, snapshot);
|
||||
if conflicts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let keys: Vec<String> = conflicts
|
||||
.iter()
|
||||
.map(|c| c.jcode.chord.display())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Some(format!(
|
||||
"Keybinding conflict: {} may be intercepted by your terminal/OS/apps. Run /keys for details.",
|
||||
keys.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keymap::KeyChord;
|
||||
use crate::keymap::source::DiscoveredBinding;
|
||||
|
||||
fn snapshot_with(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
|
||||
KeymapSnapshot {
|
||||
version: 1,
|
||||
captured_at: "0".to_string(),
|
||||
os: "macos".to_string(),
|
||||
terminal: "Ghostty".to_string(),
|
||||
terminal_version: "1.3.1".to_string(),
|
||||
bindings,
|
||||
}
|
||||
}
|
||||
|
||||
fn term(keys: &str, action: &str) -> DiscoveredBinding {
|
||||
DiscoveredBinding {
|
||||
chord: KeyChord::parse(keys).unwrap(),
|
||||
source: KeySource::Terminal,
|
||||
action: action.to_string(),
|
||||
raw: format!("{keys}={action}"),
|
||||
tool: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_lists_conflicts_with_field_names() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snap = snapshot_with(vec![term("ctrl+tab", "next_tab")]);
|
||||
let report = render_report(&cfg, &snap);
|
||||
assert!(report.contains("Ghostty 1.3.1"));
|
||||
assert!(report.contains("keybindings.model_switch_next"));
|
||||
assert!(report.contains("next_tab"));
|
||||
assert!(report.contains("Ctrl+Tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_says_clean_when_no_conflicts() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let snap = snapshot_with(vec![term("cmd+t", "new_tab")]);
|
||||
let report = render_report(&cfg, &snap);
|
||||
assert!(report.contains("No conflicts found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_present_only_on_conflict() {
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let clean = snapshot_with(vec![term("cmd+t", "new_tab")]);
|
||||
assert!(render_status_line(&cfg, &clean).is_none());
|
||||
|
||||
let dirty = snapshot_with(vec![term("ctrl+tab", "next_tab")]);
|
||||
let line = render_status_line(&cfg, &dirty).unwrap();
|
||||
assert!(line.contains("Ctrl+Tab"));
|
||||
assert!(line.contains("/keys"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Where a discovered key binding came from, and the binding record itself.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::chord::KeyChord;
|
||||
|
||||
/// The origin of a discovered binding on the machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KeySource {
|
||||
/// A macOS system-wide shortcut (`com.apple.symbolichotkeys`).
|
||||
MacosSystem,
|
||||
/// A binding declared by the terminal emulator (config or built-in default).
|
||||
Terminal,
|
||||
/// A binding declared by a third-party app that grabs global hotkeys before
|
||||
/// the terminal sees them: window managers (OmniWM, AeroSpace, yabai/skhd),
|
||||
/// automation tools (Hammerspoon), launchers (Raycast), etc. The specific
|
||||
/// app is named in [`DiscoveredBinding::tool`].
|
||||
ExternalApp,
|
||||
}
|
||||
|
||||
impl KeySource {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
KeySource::MacosSystem => "macOS system shortcut",
|
||||
KeySource::Terminal => "terminal",
|
||||
KeySource::ExternalApp => "external app",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key binding discovered on the machine that may intercept input before it
|
||||
/// reaches jcode.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredBinding {
|
||||
/// The normalized chord this binding triggers on.
|
||||
pub chord: KeyChord,
|
||||
/// Which layer owns this binding.
|
||||
pub source: KeySource,
|
||||
/// What the binding does, e.g. "Spotlight: Show search" or
|
||||
/// "copy_to_clipboard:mixed".
|
||||
pub action: String,
|
||||
/// The raw declaration we parsed, for debugging (e.g. the original config
|
||||
/// line or the symbolic-hotkey id).
|
||||
pub raw: String,
|
||||
/// For [`KeySource::ExternalApp`], the human-facing name of the app that
|
||||
/// owns this binding (e.g. "OmniWM", "AeroSpace", "skhd"). Empty for the
|
||||
/// macOS system and terminal sources, where the source label is enough.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub tool: String,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Discover key bindings declared by terminal emulators.
|
||||
//!
|
||||
//! Different terminals store bindings in different ways. The most reliable
|
||||
//! approach for Ghostty is to ask it for its *effective* binding set via
|
||||
//! `ghostty +list-keybinds`, which merges built-in defaults with the user's
|
||||
//! config. The parsing is pure and unit-tested; only [`read_ghostty_keybinds`]
|
||||
//! shells out.
|
||||
|
||||
use super::chord::KeyChord;
|
||||
use super::source::{DiscoveredBinding, KeySource};
|
||||
|
||||
/// Parse a single Ghostty keybind line of the form:
|
||||
///
|
||||
/// ```text
|
||||
/// keybind = super+shift+,=reload_config
|
||||
/// super+backspace=text:\x17
|
||||
/// ```
|
||||
///
|
||||
/// The left side (up to the first top-level `=`) is the trigger; the right side
|
||||
/// is the action. The trigger is `mod+mod+key`. Returns `None` for lines that
|
||||
/// are not bindings (comments, blanks, multi-key sequences we do not model).
|
||||
pub fn parse_ghostty_keybind_line(line: &str) -> Option<DiscoveredBinding> {
|
||||
let mut line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
// Strip an optional leading `keybind =` / `keybind:` prefix.
|
||||
if let Some(rest) = line.strip_prefix("keybind") {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':'))?;
|
||||
line = rest.trim();
|
||||
}
|
||||
|
||||
// Split trigger=action on the first '='.
|
||||
let eq = line.find('=')?;
|
||||
let trigger = line[..eq].trim();
|
||||
let action = line[eq + 1..].trim();
|
||||
if trigger.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let chord = parse_trigger(trigger)?;
|
||||
Some(DiscoveredBinding {
|
||||
chord,
|
||||
source: KeySource::Terminal,
|
||||
action: action.to_string(),
|
||||
raw: line.to_string(),
|
||||
tool: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `mod+mod+key` trigger into a chord. Returns `None` for triggers that
|
||||
/// describe a multi-key sequence (Ghostty uses `>` between chords) since we only
|
||||
/// model single chords for conflict detection.
|
||||
fn parse_trigger(trigger: &str) -> Option<KeyChord> {
|
||||
if trigger.contains('>') {
|
||||
return None;
|
||||
}
|
||||
// Ghostty exposes a few logical triggers (mapped to the platform's native
|
||||
// shortcut) that are not real key chords. They can never collide with a
|
||||
// jcode binding, so drop them to keep the snapshot clean.
|
||||
if matches!(
|
||||
trigger.to_ascii_lowercase().as_str(),
|
||||
"copy" | "paste" | "unbind" | "ignore"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let mut cmd = false;
|
||||
let mut ctrl = false;
|
||||
let mut alt = false;
|
||||
let mut shift = false;
|
||||
let mut key: Option<String> = None;
|
||||
|
||||
// Split on '+', but a trailing '+' means the key itself is '+'.
|
||||
let tokens = split_trigger_tokens(trigger);
|
||||
for tok in tokens {
|
||||
match tok.to_ascii_lowercase().as_str() {
|
||||
"super" | "cmd" | "command" => cmd = true,
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
"alt" | "opt" | "option" => alt = true,
|
||||
"shift" => shift = true,
|
||||
other => {
|
||||
// Last non-modifier token wins as the key.
|
||||
key = Some(other.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key = key?;
|
||||
Some(KeyChord::new(cmd, ctrl, alt, shift, &key))
|
||||
}
|
||||
|
||||
/// Split a trigger on '+' while treating a literal '+' key correctly. For
|
||||
/// example `super++` is `["super", "+"]` and `ctrl+shift++` is
|
||||
/// `["ctrl", "shift", "+"]`.
|
||||
fn split_trigger_tokens(trigger: &str) -> Vec<String> {
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let chars: Vec<char> = trigger.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c == '+' {
|
||||
if cur.is_empty() {
|
||||
// A '+' with nothing before it is the literal '+' key.
|
||||
// Only treat it as the key when it is not a separator between
|
||||
// two names (i.e. previous char was already a separator).
|
||||
let is_trailing_or_double = i + 1 == chars.len() || chars[i + 1] == '+';
|
||||
if is_trailing_or_double || tokens.is_empty() {
|
||||
tokens.push("+".to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Parse the full output of `ghostty +list-keybinds` (or a Ghostty config file)
|
||||
/// into discovered bindings.
|
||||
pub fn parse_ghostty_keybinds(output: &str) -> Vec<DiscoveredBinding> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(parse_ghostty_keybind_line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run `ghostty +list-keybinds` and parse its output. Returns an empty vec on
|
||||
/// any failure. Tries the bundled macOS binary first, then `ghostty` on PATH.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn read_ghostty_keybinds() -> Vec<DiscoveredBinding> {
|
||||
use std::process::Command;
|
||||
|
||||
const CANDIDATES: [&str; 2] = [
|
||||
"/Applications/Ghostty.app/Contents/MacOS/ghostty",
|
||||
"ghostty",
|
||||
];
|
||||
for bin in CANDIDATES {
|
||||
let Ok(output) = Command::new(bin).arg("+list-keybinds").output() else {
|
||||
continue;
|
||||
};
|
||||
if output.status.success() && !output.stdout.is_empty() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
return parse_ghostty_keybinds(&text);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn read_ghostty_keybinds() -> Vec<DiscoveredBinding> {
|
||||
use std::process::Command;
|
||||
let Ok(output) = Command::new("ghostty").arg("+list-keybinds").output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if output.status.success() && !output.stdout.is_empty() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
return parse_ghostty_keybinds(&text);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_listed_keybind() {
|
||||
let b = parse_ghostty_keybind_line("keybind = super+c=copy_to_clipboard:mixed").unwrap();
|
||||
assert_eq!(b.chord.canonical(), "cmd+c");
|
||||
assert_eq!(b.action, "copy_to_clipboard:mixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bare_config_line() {
|
||||
let b = parse_ghostty_keybind_line("super+backspace=text:\\x17").unwrap();
|
||||
assert_eq!(b.chord.canonical(), "cmd+backspace");
|
||||
assert_eq!(b.action, "text:\\x17");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_named_punctuation_key() {
|
||||
let b = parse_ghostty_keybind_line("keybind = super+shift+,=reload_config").unwrap();
|
||||
assert_eq!(b.chord.canonical(), "cmd+shift+,");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_digit_key() {
|
||||
let b = parse_ghostty_keybind_line("keybind = super+digit_1=goto_tab:1").unwrap();
|
||||
assert_eq!(b.chord.canonical(), "cmd+1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_literal_plus_key() {
|
||||
let b = parse_ghostty_keybind_line("keybind = super++=increase_font_size:1").unwrap();
|
||||
assert_eq!(b.chord.canonical(), "cmd++");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comments_and_blanks() {
|
||||
assert!(parse_ghostty_keybind_line("# a comment").is_none());
|
||||
assert!(parse_ghostty_keybind_line(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_multi_key_sequences() {
|
||||
assert!(parse_ghostty_keybind_line("keybind = ctrl+a>n=new_window").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_logical_copy_paste_triggers() {
|
||||
assert!(parse_ghostty_keybind_line("keybind = copy=copy_to_clipboard:mixed").is_none());
|
||||
assert!(parse_ghostty_keybind_line("keybind = paste=paste_from_clipboard").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_full_output() {
|
||||
let out = "\
|
||||
keybind = super+c=copy_to_clipboard:mixed
|
||||
keybind = super+v=paste_from_clipboard
|
||||
# comment
|
||||
keybind = super+enter=new_window
|
||||
";
|
||||
let binds = parse_ghostty_keybinds(out);
|
||||
assert_eq!(binds.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
//! Render and install global "launch a new jcode" hotkeys on Linux/niri.
|
||||
//!
|
||||
//! Unlike macOS, a Wayland client cannot grab system-wide hotkeys: the
|
||||
//! `global-hotkey` crate only works on X11/macOS. The portable, correct
|
||||
//! mechanism on Wayland is to ask the **compositor** to bind the key, so on niri
|
||||
//! we generate `bind` lines and splice them into the user's
|
||||
//! `~/.config/niri/config.kdl` `binds { }` block. niri watches its config and
|
||||
//! hot-reloads on save, so the bindings take effect without a restart.
|
||||
//!
|
||||
//! Two layers, mirroring the macOS module:
|
||||
//!
|
||||
//! * The **pure** renderer ([`render_niri_block`], [`chord_to_niri_bind`]) turns
|
||||
//! resolved launch hotkeys into the exact KDL text we manage. This is what the
|
||||
//! unit tests assert, so the bindings the user sees are exactly what we can
|
||||
//! check without touching their machine.
|
||||
//! * The **install** glue ([`splice_managed_block`]) replaces our marked region
|
||||
//! inside the existing `binds { }` block (or inserts one), leaving every other
|
||||
//! line untouched.
|
||||
//!
|
||||
//! The managed region is delimited by sentinel comments so re-installs are
|
||||
//! idempotent and a user can hand-remove it cleanly:
|
||||
//!
|
||||
//! ```text
|
||||
//! // >>> jcode launch hotkeys (managed) >>>
|
||||
//! Alt+Semicolon hotkey-overlay-title="jcode: home" { spawn "sh" "-c" "..."; }
|
||||
//! // <<< jcode launch hotkeys (managed) <<<
|
||||
//! ```
|
||||
|
||||
use crate::keymap::KeyChord;
|
||||
|
||||
/// Opening sentinel for the managed bind region inside `binds { }`.
|
||||
pub(crate) const NIRI_BLOCK_BEGIN: &str = "// >>> jcode launch hotkeys (managed) >>>";
|
||||
/// Closing sentinel for the managed bind region inside `binds { }`.
|
||||
pub(crate) const NIRI_BLOCK_END: &str = "// <<< jcode launch hotkeys (managed) <<<";
|
||||
|
||||
/// One resolved hotkey ready to render as a niri bind: the chord, the target
|
||||
/// directory, a human label, and whether it is a self-dev session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct NiriHotkey {
|
||||
pub chord: KeyChord,
|
||||
/// Concrete directory to launch jcode in (already resolved from any
|
||||
/// `$HOME`/`$LAST_DIR` sentinel).
|
||||
pub dir: String,
|
||||
/// Short human label, e.g. the repo's directory name.
|
||||
pub label: String,
|
||||
/// Pass the `self-dev` subcommand.
|
||||
pub self_dev: bool,
|
||||
}
|
||||
|
||||
/// Map a jcode modifier+key chord onto niri's KDL key syntax.
|
||||
///
|
||||
/// niri uses `+`-joined modifiers followed by an XKB key name, e.g.
|
||||
/// `Alt+Semicolon`, `Super+Shift+Apostrophe`. We translate jcode's `cmd`
|
||||
/// modifier to `Super` (the Wayland super/meta key) since there is no Command
|
||||
/// key on Linux. Returns `None` for keys niri cannot name.
|
||||
pub(crate) fn chord_to_niri_bind(chord: &KeyChord) -> Option<String> {
|
||||
let key = niri_key_name(&chord.key)?;
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
// jcode `cmd` == macOS Command == Wayland Super.
|
||||
if chord.cmd {
|
||||
parts.push("Super");
|
||||
}
|
||||
if chord.ctrl {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if chord.alt {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if chord.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
let mods = parts.join("+");
|
||||
if mods.is_empty() {
|
||||
Some(key)
|
||||
} else {
|
||||
Some(format!("{mods}+{key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a canonical jcode key token into the XKB key name niri expects.
|
||||
/// Returns `None` for tokens with no stable niri spelling.
|
||||
fn niri_key_name(key: &str) -> Option<String> {
|
||||
let named = match key {
|
||||
";" => "Semicolon",
|
||||
"'" => "Apostrophe",
|
||||
"[" => "bracketleft",
|
||||
"]" => "bracketright",
|
||||
"\\" => "backslash",
|
||||
"/" => "slash",
|
||||
"," => "comma",
|
||||
"." => "period",
|
||||
"-" => "minus",
|
||||
"=" => "equal",
|
||||
"`" => "grave",
|
||||
"left" => "Left",
|
||||
"right" => "Right",
|
||||
"up" => "Up",
|
||||
"down" => "Down",
|
||||
"pageup" => "Page_Up",
|
||||
"pagedown" => "Page_Down",
|
||||
"home" => "Home",
|
||||
"end" => "End",
|
||||
"insert" => "Insert",
|
||||
"delete" => "Delete",
|
||||
"backspace" => "BackSpace",
|
||||
"enter" => "Return",
|
||||
"esc" => "Escape",
|
||||
"tab" => "Tab",
|
||||
"space" => "space",
|
||||
other => {
|
||||
// Single letters: niri accepts the lowercase XKB name (`a`..`z`).
|
||||
if other.len() == 1 && other.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Some(other.to_string());
|
||||
}
|
||||
// Function keys f1..f24 -> F1..F24.
|
||||
if let Some(rest) = other.strip_prefix('f')
|
||||
&& !rest.is_empty()
|
||||
&& rest.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return Some(format!("F{rest}"));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(named.to_string())
|
||||
}
|
||||
|
||||
/// Escape a string for inclusion inside a KDL double-quoted string.
|
||||
fn kdl_escape(input: &str) -> String {
|
||||
input.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
/// POSIX-shell single-quote escaping for an argument passed via `sh -c`.
|
||||
fn sh_single_quote(input: &str) -> String {
|
||||
format!("'{}'", input.replace('\'', r#"'\''"#))
|
||||
}
|
||||
|
||||
/// Build the `sh -c` command string a bind runs to open jcode in `dir`.
|
||||
///
|
||||
/// We `cd` into the directory (falling back to `$HOME` if it has since been
|
||||
/// removed), then launch jcode via the user's terminal. The terminal is chosen
|
||||
/// by `terminal` (e.g. `kitty`); we pass it the jcode executable directly.
|
||||
fn launch_shell_command(exe_path: &str, terminal: &str, dir: &str, self_dev: bool) -> String {
|
||||
let dir_q = sh_single_quote(dir);
|
||||
let exe_q = sh_single_quote(exe_path);
|
||||
let term_q = sh_single_quote(terminal);
|
||||
let subcmd = if self_dev { " self-dev" } else { "" };
|
||||
// cd with $HOME fallback, then exec the terminal running jcode.
|
||||
format!(
|
||||
"if [ -d {dir_q} ]; then cd {dir_q}; else cd \"$HOME\"; fi; exec {term_q} {exe_q}{subcmd}",
|
||||
dir_q = dir_q,
|
||||
term_q = term_q,
|
||||
exe_q = exe_q,
|
||||
subcmd = subcmd,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a single niri `bind` line for one hotkey, or `None` if the chord
|
||||
/// cannot be expressed in niri.
|
||||
pub(crate) fn render_niri_bind_line(
|
||||
hotkey: &NiriHotkey,
|
||||
exe_path: &str,
|
||||
terminal: &str,
|
||||
indent: &str,
|
||||
) -> Option<String> {
|
||||
let bind = chord_to_niri_bind(&hotkey.chord)?;
|
||||
let title = if hotkey.self_dev {
|
||||
format!("jcode: {} (self-dev)", hotkey.label)
|
||||
} else {
|
||||
format!("jcode: {}", hotkey.label)
|
||||
};
|
||||
let shell = launch_shell_command(exe_path, terminal, &hotkey.dir, hotkey.self_dev);
|
||||
Some(format!(
|
||||
"{indent}{bind} hotkey-overlay-title=\"{title}\" {{ spawn \"sh\" \"-c\" \"{shell}\"; }}",
|
||||
indent = indent,
|
||||
bind = bind,
|
||||
title = kdl_escape(&title),
|
||||
shell = kdl_escape(&shell),
|
||||
))
|
||||
}
|
||||
|
||||
/// Render the full managed block (sentinels + one bind per hotkey), indented to
|
||||
/// sit inside `binds { }`. Hotkeys niri cannot express are skipped. Returns
|
||||
/// `None` when no hotkey could be rendered.
|
||||
pub(crate) fn render_niri_block(
|
||||
hotkeys: &[NiriHotkey],
|
||||
exe_path: &str,
|
||||
terminal: &str,
|
||||
indent: &str,
|
||||
) -> Option<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for hk in hotkeys {
|
||||
if let Some(line) = render_niri_bind_line(hk, exe_path, terminal, indent) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
out.push_str(indent);
|
||||
out.push_str(NIRI_BLOCK_BEGIN);
|
||||
out.push('\n');
|
||||
for line in &lines {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(indent);
|
||||
out.push_str(NIRI_BLOCK_END);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Result of splicing the managed block into a config: the new text plus whether
|
||||
/// anything actually changed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SpliceResult {
|
||||
pub text: String,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Splice `block` (a fully-rendered managed region, no trailing newline) into
|
||||
/// `config`'s `binds { }` section.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - If a previous managed region (between the sentinels) exists, replace it in
|
||||
/// place. This keeps re-installs idempotent and position-stable.
|
||||
/// - Otherwise, insert the block just inside the opening `binds {` line.
|
||||
/// - If there is no `binds {` block at all, append a fresh `binds { ... }` at
|
||||
/// the end of the file.
|
||||
///
|
||||
/// Returns `changed = false` (and the original text) when the existing managed
|
||||
/// region already equals `block`, so callers can skip a no-op write.
|
||||
pub(crate) fn splice_managed_block(config: &str, block: &str) -> SpliceResult {
|
||||
// 1) Replace an existing managed region if present.
|
||||
if let (Some(begin_idx), Some(end_line_end)) = find_managed_region(config) {
|
||||
// begin_idx is the byte offset of the start of the BEGIN line; the
|
||||
// managed region runs through the end of the END line (including its
|
||||
// trailing newline). Re-emit `block` plus that newline so the result is
|
||||
// byte-identical to a fresh insert (keeps re-installs idempotent).
|
||||
let before = &config[..begin_idx];
|
||||
let after = &config[end_line_end..];
|
||||
let new_text = format!("{before}{block}\n{after}");
|
||||
let changed = new_text != config;
|
||||
return SpliceResult {
|
||||
text: new_text,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Insert just inside an existing `binds {` block.
|
||||
if let Some(insert_at) = binds_block_insert_point(config) {
|
||||
let before = &config[..insert_at];
|
||||
let after = &config[insert_at..];
|
||||
// Terminate the block with a newline so the END sentinel never runs into
|
||||
// the following bind line (which would later swallow it on replace).
|
||||
let new_text = format!("{before}{block}\n{after}");
|
||||
return SpliceResult {
|
||||
text: new_text,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) No binds block: append a new one.
|
||||
let mut new_text = config.to_string();
|
||||
if !new_text.is_empty() && !new_text.ends_with('\n') {
|
||||
new_text.push('\n');
|
||||
}
|
||||
new_text.push_str("\nbinds {\n");
|
||||
new_text.push_str(block);
|
||||
new_text.push('\n');
|
||||
new_text.push_str("}\n");
|
||||
SpliceResult {
|
||||
text: new_text,
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte range of an existing managed region: `(start_of_BEGIN_line,
|
||||
/// end_of_END_line_including_newline)`. Returns `None` if either sentinel is
|
||||
/// missing.
|
||||
fn find_managed_region(config: &str) -> (Option<usize>, Option<usize>) {
|
||||
let Some(begin_pos) = config.find(NIRI_BLOCK_BEGIN) else {
|
||||
return (None, None);
|
||||
};
|
||||
// Back up to the start of the BEGIN line (include its indentation).
|
||||
let line_start = config[..begin_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||||
|
||||
let Some(end_pos) = config[begin_pos..].find(NIRI_BLOCK_END) else {
|
||||
return (Some(line_start), None);
|
||||
};
|
||||
let end_abs = begin_pos + end_pos;
|
||||
// Extend through the rest of the END line, including its trailing newline.
|
||||
let line_end = match config[end_abs..].find('\n') {
|
||||
Some(nl) => end_abs + nl + 1,
|
||||
None => config.len(),
|
||||
};
|
||||
(Some(line_start), Some(line_end))
|
||||
}
|
||||
|
||||
/// Byte offset just after the first `binds {` opening line's newline, i.e. the
|
||||
/// point to insert new binds so they land inside the block. Returns `None` if no
|
||||
/// `binds {` block exists.
|
||||
fn binds_block_insert_point(config: &str) -> Option<usize> {
|
||||
for (idx, line) in line_offsets(config) {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("binds") && trimmed.trim_end().ends_with('{') {
|
||||
// Insert right after this line's terminating newline.
|
||||
let after_line = idx + line.len();
|
||||
// line includes no newline; advance past it if present.
|
||||
return Some(if config[after_line..].starts_with('\n') {
|
||||
after_line + 1
|
||||
} else {
|
||||
after_line
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Iterate `(byte_offset, line_without_newline)` pairs.
|
||||
fn line_offsets(s: &str) -> Vec<(usize, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, ch) in s.char_indices() {
|
||||
if ch == '\n' {
|
||||
out.push((start, &s[start..i]));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if start < s.len() {
|
||||
out.push((start, &s[start..]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chord(s: &str) -> KeyChord {
|
||||
KeyChord::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn hk(chord_str: &str, dir: &str, label: &str, self_dev: bool) -> NiriHotkey {
|
||||
NiriHotkey {
|
||||
chord: chord(chord_str),
|
||||
dir: dir.to_string(),
|
||||
label: label.to_string(),
|
||||
self_dev,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_common_chords_to_niri_syntax() {
|
||||
// cmd maps to Super on Linux.
|
||||
assert_eq!(
|
||||
chord_to_niri_bind(&chord("cmd+;")).unwrap(),
|
||||
"Super+Semicolon"
|
||||
);
|
||||
assert_eq!(
|
||||
chord_to_niri_bind(&chord("cmd+shift+'")).unwrap(),
|
||||
"Super+Shift+Apostrophe"
|
||||
);
|
||||
assert_eq!(
|
||||
chord_to_niri_bind(&chord("alt+[")).unwrap(),
|
||||
"Alt+bracketleft"
|
||||
);
|
||||
assert_eq!(
|
||||
chord_to_niri_bind(&chord("ctrl+\\")).unwrap(),
|
||||
"Ctrl+backslash"
|
||||
);
|
||||
assert_eq!(chord_to_niri_bind(&chord("alt+b")).unwrap(), "Alt+b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unmappable_keys() {
|
||||
// An empty/odd token has no niri name.
|
||||
assert!(niri_key_name("scrolllock").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_bind_line_with_cd_and_terminal() {
|
||||
let line = render_niri_bind_line(
|
||||
&hk("alt+;", "/home/jeremy/jcode", "jcode", true),
|
||||
"/home/jeremy/.local/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(line.contains("Alt+Semicolon"));
|
||||
assert!(line.contains("self-dev"));
|
||||
assert!(line.contains("hotkey-overlay-title=\"jcode: jcode (self-dev)\""));
|
||||
assert!(line.contains("spawn \"sh\" \"-c\""));
|
||||
assert!(line.contains("/home/jeremy/jcode"));
|
||||
assert!(line.starts_with(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_block_wraps_sentinels() {
|
||||
let block = render_niri_block(
|
||||
&[
|
||||
hk("alt+;", "/home/u", "home", false),
|
||||
hk("alt+'", "/home/u/proj", "proj", false),
|
||||
],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(block.starts_with(" // >>> jcode launch hotkeys (managed) >>>"));
|
||||
assert!(
|
||||
block
|
||||
.trim_end()
|
||||
.ends_with("// <<< jcode launch hotkeys (managed) <<<")
|
||||
);
|
||||
assert_eq!(block.matches("spawn").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_inserts_into_existing_binds_block() {
|
||||
let cfg = "binds {\n Alt+Tab { focus-window-previous; }\n}\n";
|
||||
let block = render_niri_block(
|
||||
&[hk("alt+;", "/home/u", "home", false)],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
let res = splice_managed_block(cfg, &block);
|
||||
assert!(res.changed);
|
||||
assert!(res.text.contains(NIRI_BLOCK_BEGIN));
|
||||
assert!(res.text.contains("Alt+Tab { focus-window-previous; }"));
|
||||
// Managed block sits after the binds { line.
|
||||
let binds_idx = res.text.find("binds {").unwrap();
|
||||
let begin_idx = res.text.find(NIRI_BLOCK_BEGIN).unwrap();
|
||||
assert!(begin_idx > binds_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_replaces_existing_managed_region_in_place() {
|
||||
let block_v1 = render_niri_block(
|
||||
&[hk("alt+;", "/home/u", "home", false)],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = format!("binds {{\n{block_v1}\n Alt+Tab {{ focus-window-previous; }}\n}}\n");
|
||||
|
||||
let block_v2 = render_niri_block(
|
||||
&[hk("alt+;", "/home/u/newproj", "newproj", false)],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
let res = splice_managed_block(&cfg, &block_v2);
|
||||
assert!(res.changed);
|
||||
// Only one managed region.
|
||||
assert_eq!(res.text.matches(NIRI_BLOCK_BEGIN).count(), 1);
|
||||
assert!(res.text.contains("newproj"));
|
||||
assert!(!res.text.contains("\"home\""));
|
||||
// Untouched bind preserved.
|
||||
assert!(res.text.contains("Alt+Tab { focus-window-previous; }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_is_idempotent() {
|
||||
let block = render_niri_block(
|
||||
&[hk("alt+;", "/home/u", "home", false)],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = "binds {\n Alt+Tab { focus-window-previous; }\n}\n";
|
||||
let first = splice_managed_block(cfg, &block);
|
||||
let second = splice_managed_block(&first.text, &block);
|
||||
assert!(!second.changed);
|
||||
assert_eq!(first.text, second.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_appends_binds_block_when_missing() {
|
||||
let block = render_niri_block(
|
||||
&[hk("alt+;", "/home/u", "home", false)],
|
||||
"/bin/jcode",
|
||||
"kitty",
|
||||
" ",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = "// no binds here\noutput \"eDP-1\" {}\n";
|
||||
let res = splice_managed_block(cfg, &block);
|
||||
assert!(res.changed);
|
||||
assert!(res.text.contains("binds {"));
|
||||
assert!(res.text.contains(NIRI_BLOCK_BEGIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_cds_and_self_devs() {
|
||||
let s = launch_shell_command("/bin/jcode", "kitty", "/home/u/proj", true);
|
||||
assert!(s.contains("cd '/home/u/proj'"));
|
||||
assert!(s.contains("exec 'kitty' '/bin/jcode' self-dev"));
|
||||
assert!(s.contains("\"$HOME\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::{
|
||||
MacTerminalKind, SetupHintsState, effective_macos_terminal, escape_applescript_text,
|
||||
escape_shell_single_quotes, launch_command_for_macos_terminal, paused_jcode_shell_command,
|
||||
save_preferred_macos_terminal,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MACOS_APP_ICON_FILE_NAME: &str = "Jcode.icns";
|
||||
const MACOS_APP_ICON_BYTES: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../assets/app-icons/Jcode.icns"
|
||||
));
|
||||
|
||||
pub(super) fn should_refresh_macos_app_launcher(state: &SetupHintsState) -> bool {
|
||||
match (macos_app_launcher_dir(), legacy_macos_app_launcher_dir()) {
|
||||
(Ok(app_dir), Ok(legacy_app_dir)) => {
|
||||
should_refresh_macos_app_launcher_paths(state, &app_dir, &legacy_app_dir)
|
||||
}
|
||||
_ => !state.desktop_shortcut_created,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install_macos_app_launcher() -> Result<(PathBuf, MacTerminalKind)> {
|
||||
let app_dir = macos_app_launcher_dir()?;
|
||||
let legacy_app_dir = legacy_macos_app_launcher_dir()?;
|
||||
|
||||
if app_dir.exists() && !macos_app_launcher_is_valid(&app_dir) {
|
||||
remove_path_if_exists(&app_dir)?;
|
||||
}
|
||||
if legacy_app_dir != app_dir && legacy_app_dir.exists() {
|
||||
remove_path_if_exists(&legacy_app_dir)?;
|
||||
}
|
||||
|
||||
let contents_dir = app_dir.join("Contents");
|
||||
let macos_dir = contents_dir.join("MacOS");
|
||||
let resources_dir = contents_dir.join("Resources");
|
||||
std::fs::create_dir_all(&macos_dir)?;
|
||||
std::fs::create_dir_all(&resources_dir)?;
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe_path = exe.to_string_lossy().into_owned();
|
||||
let terminal = effective_macos_terminal();
|
||||
let launcher_path = macos_dir.join("jcode-launcher");
|
||||
let launcher_script = macos_launcher_script(terminal, &exe_path, &app_dir);
|
||||
std::fs::write(&launcher_path, launcher_script)?;
|
||||
std::fs::write(
|
||||
resources_dir.join(MACOS_APP_ICON_FILE_NAME),
|
||||
MACOS_APP_ICON_BYTES,
|
||||
)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
let info_plist = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Jcode</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Jcode</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.jcode.launcher</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{version}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{version}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>jcode-launcher</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>{icon_file}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#,
|
||||
version = jcode_build_meta::VERSION,
|
||||
icon_file = MACOS_APP_ICON_FILE_NAME,
|
||||
);
|
||||
std::fs::write(contents_dir.join("Info.plist"), info_plist)?;
|
||||
|
||||
if !macos_app_launcher_is_valid(&app_dir) {
|
||||
anyhow::bail!(
|
||||
"launcher bundle is incomplete after setup: {}",
|
||||
app_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
register_macos_app_launcher(&app_dir);
|
||||
save_preferred_macos_terminal(terminal)?;
|
||||
Ok((app_dir, terminal))
|
||||
}
|
||||
|
||||
fn macos_app_launcher_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("Could not find home directory")?;
|
||||
Ok(home.join("Applications").join("Jcode.app"))
|
||||
}
|
||||
|
||||
fn legacy_macos_app_launcher_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("Could not find home directory")?;
|
||||
Ok(home.join("Applications").join("jcode.app"))
|
||||
}
|
||||
|
||||
fn macos_app_launcher_info_plist_path(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join("Contents").join("Info.plist")
|
||||
}
|
||||
|
||||
fn macos_app_launcher_executable_path(app_dir: &Path) -> PathBuf {
|
||||
app_dir
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("jcode-launcher")
|
||||
}
|
||||
|
||||
fn macos_app_launcher_icon_path(app_dir: &Path) -> PathBuf {
|
||||
app_dir
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join(MACOS_APP_ICON_FILE_NAME)
|
||||
}
|
||||
|
||||
fn macos_app_launcher_is_valid(app_dir: &Path) -> bool {
|
||||
app_dir.is_dir()
|
||||
&& macos_app_launcher_info_plist_path(app_dir).is_file()
|
||||
&& macos_app_launcher_executable_path(app_dir).is_file()
|
||||
&& macos_app_launcher_icon_path(app_dir).is_file()
|
||||
}
|
||||
|
||||
fn remove_path_if_exists(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let metadata = std::fs::symlink_metadata(path)
|
||||
.with_context(|| format!("failed to inspect existing path {}", path.display()))?;
|
||||
if metadata.file_type().is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
.with_context(|| format!("failed to remove directory {}", path.display()))?;
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("failed to remove file {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_macos_app_launcher(app_dir: &Path) {
|
||||
let _ = std::process::Command::new("touch").arg(app_dir).status();
|
||||
|
||||
let lsregister = Path::new(
|
||||
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
||||
);
|
||||
if lsregister.exists() {
|
||||
let _ = std::process::Command::new(lsregister)
|
||||
.args(["-f", app_dir.to_string_lossy().as_ref()])
|
||||
.status();
|
||||
}
|
||||
|
||||
let _ = std::process::Command::new("mdimport").arg(app_dir).status();
|
||||
}
|
||||
|
||||
fn should_refresh_macos_app_launcher_paths(
|
||||
state: &SetupHintsState,
|
||||
app_dir: &Path,
|
||||
legacy_app_dir: &Path,
|
||||
) -> bool {
|
||||
!state.desktop_shortcut_created
|
||||
|| !macos_app_launcher_is_valid(app_dir)
|
||||
|| path_exists_with_exact_name(legacy_app_dir)
|
||||
}
|
||||
|
||||
/// Check that `path` exists under its exact byte-for-byte file name.
|
||||
///
|
||||
/// macOS system volumes are case-insensitive by default, so a plain
|
||||
/// `Path::exists()` on `jcode.app` also matches `Jcode.app`. The legacy-bundle
|
||||
/// check needs an exact-name match or the launcher would refresh itself on
|
||||
/// every launch once the new bundle exists.
|
||||
fn path_exists_with_exact_name(path: &Path) -> bool {
|
||||
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
|
||||
return path.exists();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(parent) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.any(|entry| entry.file_name() == name)
|
||||
}
|
||||
|
||||
fn macos_launcher_script(terminal: MacTerminalKind, exe_path: &str, app_dir: &Path) -> String {
|
||||
let app_dir_escaped = escape_shell_single_quotes(&app_dir.to_string_lossy());
|
||||
let exe_path_escaped = escape_shell_single_quotes(exe_path);
|
||||
let shell_command = paused_jcode_shell_command(exe_path);
|
||||
let launch_command = launch_command_for_macos_terminal(terminal, &shell_command);
|
||||
let missing_message = escape_applescript_text(&format!(
|
||||
"Jcode could not launch because the executable was not found.\n\nExpected path:\n{}\n\nTry reinstalling jcode or rerun:\njcode setup-launcher",
|
||||
exe_path
|
||||
));
|
||||
let terminal_failure_message = escape_applescript_text(&format!(
|
||||
"Jcode could not open {}.\n\nTry rerunning:\njcode setup-launcher\n\nLauncher log:\n~/.jcode/launcher/macos-launcher.log",
|
||||
terminal.label()
|
||||
));
|
||||
|
||||
format!(
|
||||
r#"#!/bin/bash
|
||||
set -u
|
||||
|
||||
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
LOG_DIR="$HOME/.jcode/launcher"
|
||||
LOG_FILE="$LOG_DIR/macos-launcher.log"
|
||||
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
|
||||
|
||||
show_missing_executable() {{
|
||||
/usr/bin/osascript <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
display alert "Jcode launch failed" message "{missing_message}" as critical
|
||||
APPLESCRIPT
|
||||
}}
|
||||
|
||||
show_terminal_launch_failure() {{
|
||||
/usr/bin/osascript <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
||||
display alert "Jcode launch failed" message "{terminal_failure_message}" as critical
|
||||
APPLESCRIPT
|
||||
}}
|
||||
|
||||
if [ ! -x '{exe_path_escaped}' ]; then
|
||||
printf '[%s] missing executable: {exe_path}\n' "$(date '+%Y-%m-%d %H:%M:%S')" >>"$LOG_FILE" 2>&1
|
||||
show_missing_executable
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{launch_command} >>"$LOG_FILE" 2>&1
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
printf '[%s] terminal launch failed for {terminal_name} (status %s)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$status" >>"$LOG_FILE" 2>&1
|
||||
show_terminal_launch_failure
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
/usr/bin/touch '{app_dir_escaped}' >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
"#,
|
||||
missing_message = missing_message,
|
||||
terminal_failure_message = terminal_failure_message,
|
||||
exe_path = exe_path,
|
||||
exe_path_escaped = exe_path_escaped,
|
||||
terminal_name = terminal.label(),
|
||||
launch_command = launch_command,
|
||||
app_dir_escaped = app_dir_escaped,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "macos_launcher_tests.rs"]
|
||||
mod macos_launcher_tests;
|
||||
@@ -0,0 +1,142 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_script_shows_alerts_and_uses_terminal_launcher() {
|
||||
let script = macos_launcher_script(
|
||||
MacTerminalKind::Ghostty,
|
||||
"/tmp/jcode",
|
||||
Path::new("/Users/test/Applications/Jcode.app"),
|
||||
);
|
||||
assert!(script.contains("display alert \"Jcode launch failed\""));
|
||||
assert!(script.contains("jcode setup-launcher"));
|
||||
assert!(script.contains("/usr/bin/open -na Ghostty"));
|
||||
assert!(script.contains("macos-launcher.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_icon_asset_is_valid_icns_container() {
|
||||
assert!(MACOS_APP_ICON_BYTES.starts_with(b"icns"));
|
||||
assert!(MACOS_APP_ICON_BYTES.len() > 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_refreshes_when_new_bundle_missing() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_refreshes_when_legacy_bundle_exists() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
std::fs::create_dir_all(&app_dir).expect("create new app dir");
|
||||
std::fs::create_dir_all(&legacy_app_dir).expect("create legacy app dir");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_refreshes_when_new_bundle_is_plain_file() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
std::fs::write(&app_dir, "broken").expect("write broken launcher file");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_refreshes_when_bundle_is_incomplete() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
std::fs::create_dir_all(app_dir.join("Contents")).expect("create incomplete bundle");
|
||||
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(!macos_app_launcher_is_valid(&app_dir));
|
||||
assert!(should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_does_not_refresh_when_new_bundle_exists() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
std::fs::create_dir_all(app_dir.join("Contents").join("MacOS")).expect("create new app dir");
|
||||
std::fs::create_dir_all(app_dir.join("Contents").join("Resources"))
|
||||
.expect("create resources dir");
|
||||
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
|
||||
std::fs::write(macos_app_launcher_executable_path(&app_dir), "#!/bin/sh\n")
|
||||
.expect("write launcher executable");
|
||||
std::fs::write(macos_app_launcher_icon_path(&app_dir), MACOS_APP_ICON_BYTES)
|
||||
.expect("write launcher icon");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(macos_app_launcher_is_valid(&app_dir));
|
||||
assert!(!should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launcher_refreshes_when_icon_missing() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let app_dir = temp.path().join("Jcode.app");
|
||||
let legacy_app_dir = temp.path().join("jcode.app");
|
||||
std::fs::create_dir_all(app_dir.join("Contents").join("MacOS")).expect("create new app dir");
|
||||
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
|
||||
std::fs::write(macos_app_launcher_executable_path(&app_dir), "#!/bin/sh\n")
|
||||
.expect("write launcher executable");
|
||||
let state = SetupHintsState {
|
||||
desktop_shortcut_created: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(!macos_app_launcher_is_valid(&app_dir));
|
||||
assert!(should_refresh_macos_app_launcher_paths(
|
||||
&state,
|
||||
&app_dir,
|
||||
&legacy_app_dir,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
use anyhow::Result;
|
||||
use jcode_storage as storage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MacTerminalKind {
|
||||
Ghostty,
|
||||
Iterm2,
|
||||
AppleTerminal,
|
||||
WezTerm,
|
||||
Warp,
|
||||
Alacritty,
|
||||
Vscode,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl MacTerminalKind {
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ghostty => "Ghostty",
|
||||
Self::Iterm2 => "iTerm2",
|
||||
Self::AppleTerminal => "Terminal.app",
|
||||
Self::WezTerm => "WezTerm",
|
||||
Self::Warp => "Warp",
|
||||
Self::Alacritty => "Alacritty",
|
||||
Self::Vscode => "VS Code terminal",
|
||||
Self::Unknown => "your current terminal",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cli_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ghostty => "ghostty",
|
||||
Self::Iterm2 => "iterm2",
|
||||
Self::AppleTerminal => "terminal",
|
||||
Self::WezTerm => "wezterm",
|
||||
Self::Warp => "warp",
|
||||
Self::Alacritty => "alacritty",
|
||||
Self::Vscode => "vscode",
|
||||
Self::Unknown => "terminal",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_cli_value(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"ghostty" => Some(Self::Ghostty),
|
||||
"iterm2" | "iterm" => Some(Self::Iterm2),
|
||||
"terminal" | "terminal.app" | "apple_terminal" => Some(Self::AppleTerminal),
|
||||
"wezterm" => Some(Self::WezTerm),
|
||||
"warp" => Some(Self::Warp),
|
||||
"alacritty" => Some(Self::Alacritty),
|
||||
"vscode" | "code" => Some(Self::Vscode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_command_app_and_args(self) -> Option<(&'static str, &'static str)> {
|
||||
match self {
|
||||
Self::Ghostty => Some(("Ghostty", "-e /bin/bash -lc")),
|
||||
Self::Alacritty => Some(("Alacritty", "-e /bin/bash -lc")),
|
||||
Self::WezTerm => Some(("WezTerm", "start --always-new-process -- /bin/bash -lc")),
|
||||
Self::Iterm2 | Self::AppleTerminal | Self::Warp | Self::Vscode | Self::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MacTerminalKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MacTerminalPreference {
|
||||
terminal: String,
|
||||
}
|
||||
|
||||
fn mac_terminal_pref_path() -> Result<PathBuf> {
|
||||
Ok(storage::jcode_dir()?.join("preferred_terminal.json"))
|
||||
}
|
||||
|
||||
pub(super) fn load_preferred_macos_terminal() -> Option<MacTerminalKind> {
|
||||
let path = mac_terminal_pref_path().ok()?;
|
||||
let pref: MacTerminalPreference = storage::read_json(&path).ok()?;
|
||||
MacTerminalKind::from_cli_value(&pref.terminal)
|
||||
}
|
||||
|
||||
pub(super) fn save_preferred_macos_terminal(terminal: MacTerminalKind) -> Result<()> {
|
||||
let path = mac_terminal_pref_path()?;
|
||||
storage::write_json(
|
||||
&path,
|
||||
&MacTerminalPreference {
|
||||
terminal: terminal.cli_value().to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn effective_macos_terminal() -> MacTerminalKind {
|
||||
// Precedence: config.toml `[terminal] preferred` (discoverable, documented)
|
||||
// > legacy `~/.jcode/preferred_terminal.json` > runtime detection.
|
||||
config_preferred_macos_terminal()
|
||||
.or_else(load_preferred_macos_terminal)
|
||||
.unwrap_or_else(detect_macos_terminal)
|
||||
}
|
||||
|
||||
/// Read `[terminal] preferred` from `~/.jcode/config.toml`, if present and valid.
|
||||
fn config_preferred_macos_terminal() -> Option<MacTerminalKind> {
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Wrapper {
|
||||
#[serde(default)]
|
||||
terminal: jcode_config_types::TerminalConfig,
|
||||
}
|
||||
let dir = storage::jcode_dir().ok()?;
|
||||
let text = std::fs::read_to_string(dir.join("config.toml")).ok()?;
|
||||
let wrapper = toml::from_str::<Wrapper>(&text).ok()?;
|
||||
let preferred = wrapper.terminal.preferred?;
|
||||
MacTerminalKind::from_cli_value(&preferred)
|
||||
}
|
||||
|
||||
fn detect_macos_terminal() -> MacTerminalKind {
|
||||
let term_program = std::env::var("TERM_PROGRAM")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
|
||||
|
||||
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|
||||
|| std::env::var("GHOSTTY_BIN_DIR").is_ok()
|
||||
|| term_program == "ghostty"
|
||||
|| term.contains("ghostty")
|
||||
{
|
||||
return MacTerminalKind::Ghostty;
|
||||
}
|
||||
|
||||
match term_program.as_str() {
|
||||
"iterm.app" => MacTerminalKind::Iterm2,
|
||||
"apple_terminal" => MacTerminalKind::AppleTerminal,
|
||||
"wezterm" => MacTerminalKind::WezTerm,
|
||||
"vscode" => MacTerminalKind::Vscode,
|
||||
_ => {
|
||||
if term.contains("alacritty") {
|
||||
MacTerminalKind::Alacritty
|
||||
} else if term.contains("warp") {
|
||||
MacTerminalKind::Warp
|
||||
} else {
|
||||
MacTerminalKind::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn escape_shell_single_quotes(input: &str) -> String {
|
||||
input.replace('\'', r#"'\''"#)
|
||||
}
|
||||
|
||||
pub(super) fn escape_applescript_text(input: &str) -> String {
|
||||
input.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
pub(super) fn paused_jcode_shell_command(exe_path: &str) -> String {
|
||||
paused_jcode_shell_command_with_args(exe_path, &[])
|
||||
}
|
||||
|
||||
/// Like [`paused_jcode_shell_command`] but passes extra CLI args (each
|
||||
/// single-quoted) to the jcode invocation, e.g. `--resume <session-id>`.
|
||||
pub(super) 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,
|
||||
)
|
||||
}
|
||||
|
||||
fn open_command_for_terminal(app_name: &str, app_args: &str, shell_command: &str) -> String {
|
||||
let escaped_shell = escape_shell_single_quotes(shell_command);
|
||||
format!("/usr/bin/open -na {app_name} --args {app_args} '{escaped_shell}'")
|
||||
}
|
||||
|
||||
/// Wrap a POSIX/bash launcher snippet so it always runs under bash, regardless
|
||||
/// of the user's login shell.
|
||||
///
|
||||
/// macOS `do script` (Terminal.app/Warp/VS Code) and iTerm's `command` run the
|
||||
/// string in the user's *default login shell*. Our launcher snippet is plain
|
||||
/// bash (`status=$?`, `[ ... ]`, `read -r _`), which non-POSIX shells like fish
|
||||
/// cannot parse (`fish: Unsupported use of '='`). Running it through
|
||||
/// `/bin/bash -lc` makes the launcher shell-agnostic, matching what the
|
||||
/// open-command terminals (Ghostty/Alacritty/WezTerm) already do.
|
||||
fn run_in_bash_login(shell_command: &str) -> String {
|
||||
format!(
|
||||
"/bin/bash -lc '{}'",
|
||||
escape_shell_single_quotes(shell_command)
|
||||
)
|
||||
}
|
||||
|
||||
fn applescript_command_for_terminal(app_name: &str, shell_command: &str) -> String {
|
||||
let bash_command = run_in_bash_login(shell_command);
|
||||
format!(
|
||||
"/usr/bin/osascript <<'APPLESCRIPT'\ntell application \"{app_name}\"\n activate\n do script \"{}\"\nend tell\nAPPLESCRIPT",
|
||||
escape_applescript_text(&bash_command)
|
||||
)
|
||||
}
|
||||
|
||||
fn applescript_command_for_iterm(shell_command: &str) -> String {
|
||||
let bash_command = run_in_bash_login(shell_command);
|
||||
format!(
|
||||
"/usr/bin/osascript <<'APPLESCRIPT'\ntell application \"iTerm2\"\n create window with default profile command \"{}\"\n activate\nend tell\nAPPLESCRIPT",
|
||||
escape_applescript_text(&bash_command)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn launch_command_for_macos_terminal(
|
||||
terminal: MacTerminalKind,
|
||||
shell_command: &str,
|
||||
) -> String {
|
||||
if let Some((app_name, app_args)) = terminal.open_command_app_and_args() {
|
||||
return open_command_for_terminal(app_name, app_args, shell_command);
|
||||
}
|
||||
|
||||
match terminal {
|
||||
MacTerminalKind::Iterm2 => applescript_command_for_iterm(shell_command),
|
||||
MacTerminalKind::AppleTerminal
|
||||
| MacTerminalKind::Warp
|
||||
| MacTerminalKind::Vscode
|
||||
| MacTerminalKind::Unknown => applescript_command_for_terminal("Terminal", shell_command),
|
||||
MacTerminalKind::Ghostty | MacTerminalKind::WezTerm | MacTerminalKind::Alacritty => {
|
||||
unreachable!("open-command terminals should be handled above")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn launch_script_for_macos_terminal(
|
||||
terminal: MacTerminalKind,
|
||||
shell_command: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"#!/bin/bash\nset -e\n{}\n",
|
||||
launch_command_for_macos_terminal(terminal, shell_command)
|
||||
)
|
||||
}
|
||||
|
||||
/// How to launch a shell command in a new terminal window without Apple
|
||||
/// Events automation. Background helpers (the menu bar app, launchd agents)
|
||||
/// cannot reliably get the "control Terminal" TCC permission that the
|
||||
/// AppleScript launch path needs, so they use this strategy instead.
|
||||
pub(super) enum NoAutomationLaunch {
|
||||
/// Run this shell command directly (terminals launchable via
|
||||
/// `open -na <App> --args ...`).
|
||||
Shell(String),
|
||||
/// Write the shell command to an executable `.command` file and open it
|
||||
/// with the named app (`None` = system default handler, Terminal.app).
|
||||
CommandFile { app: Option<&'static str> },
|
||||
}
|
||||
|
||||
pub(super) fn no_automation_launch(
|
||||
terminal: MacTerminalKind,
|
||||
shell_command: &str,
|
||||
) -> NoAutomationLaunch {
|
||||
if let Some((app_name, app_args)) = terminal.open_command_app_and_args() {
|
||||
return NoAutomationLaunch::Shell(open_command_for_terminal(
|
||||
app_name,
|
||||
app_args,
|
||||
shell_command,
|
||||
));
|
||||
}
|
||||
match terminal {
|
||||
MacTerminalKind::Iterm2 => NoAutomationLaunch::CommandFile { app: Some("iTerm") },
|
||||
_ => NoAutomationLaunch::CommandFile { app: None },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MacTerminalKind, applescript_command_for_iterm, applescript_command_for_terminal,
|
||||
launch_command_for_macos_terminal, open_command_for_terminal,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn from_cli_value_maps_documented_config_values() {
|
||||
// These are the values documented for `[terminal] preferred` in
|
||||
// config.toml; they must round-trip to a known terminal kind (#401).
|
||||
let cases = [
|
||||
("ghostty", MacTerminalKind::Ghostty),
|
||||
("iterm2", MacTerminalKind::Iterm2),
|
||||
("iterm", MacTerminalKind::Iterm2),
|
||||
("terminal", MacTerminalKind::AppleTerminal),
|
||||
("wezterm", MacTerminalKind::WezTerm),
|
||||
("warp", MacTerminalKind::Warp),
|
||||
("alacritty", MacTerminalKind::Alacritty),
|
||||
("vscode", MacTerminalKind::Vscode),
|
||||
("code", MacTerminalKind::Vscode),
|
||||
(" Ghostty ", MacTerminalKind::Ghostty),
|
||||
];
|
||||
for (value, expected) in cases {
|
||||
assert_eq!(
|
||||
MacTerminalKind::from_cli_value(value),
|
||||
Some(expected),
|
||||
"config value {value:?} should map to {expected:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(MacTerminalKind::from_cli_value("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_command_terminals_use_open_with_expected_args() {
|
||||
let shell_command = "printf 'hi'";
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::Ghostty, shell_command),
|
||||
open_command_for_terminal("Ghostty", "-e /bin/bash -lc", shell_command)
|
||||
);
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::Alacritty, shell_command),
|
||||
open_command_for_terminal("Alacritty", "-e /bin/bash -lc", shell_command)
|
||||
);
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::WezTerm, shell_command),
|
||||
open_command_for_terminal(
|
||||
"WezTerm",
|
||||
"start --always-new-process -- /bin/bash -lc",
|
||||
shell_command,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_shell_command_quotes_extra_args() {
|
||||
let cmd = super::paused_jcode_shell_command_with_args(
|
||||
"/usr/local/bin/jcode",
|
||||
&["--resume".to_string(), "session_fox_123_abc".to_string()],
|
||||
);
|
||||
assert!(cmd.contains("'/usr/local/bin/jcode' '--resume' 'session_fox_123_abc';"));
|
||||
|
||||
// Single quotes in args must be escaped, not break out of quoting.
|
||||
let cmd = super::paused_jcode_shell_command_with_args(
|
||||
"/usr/local/bin/jcode",
|
||||
&["it's".to_string()],
|
||||
);
|
||||
assert!(cmd.contains(r#"'it'\''s'"#));
|
||||
|
||||
// No args matches the plain command.
|
||||
assert_eq!(
|
||||
super::paused_jcode_shell_command_with_args("/usr/local/bin/jcode", &[]),
|
||||
super::paused_jcode_shell_command("/usr/local/bin/jcode"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applescript_terminals_use_expected_launcher_commands() {
|
||||
let shell_command = r#"echo "hi""#;
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::Iterm2, shell_command),
|
||||
applescript_command_for_iterm(shell_command)
|
||||
);
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::AppleTerminal, shell_command),
|
||||
applescript_command_for_terminal("Terminal", shell_command)
|
||||
);
|
||||
assert_eq!(
|
||||
launch_command_for_macos_terminal(MacTerminalKind::Warp, shell_command),
|
||||
applescript_command_for_terminal("Terminal", shell_command)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applescript_launchers_run_under_bash_for_non_bash_login_shells() {
|
||||
// The launcher snippet is bash-specific (`status=$?`). `do script` and
|
||||
// iTerm `command` run in the user's login shell, so a fish/zsh-quirky
|
||||
// login shell would otherwise choke ("fish: Unsupported use of '='").
|
||||
// Both must wrap the snippet in `/bin/bash -lc`.
|
||||
let shell_command = super::paused_jcode_shell_command("/usr/local/bin/jcode");
|
||||
assert!(shell_command.contains("status=$?"));
|
||||
|
||||
for terminal in [
|
||||
MacTerminalKind::AppleTerminal,
|
||||
MacTerminalKind::Warp,
|
||||
MacTerminalKind::Vscode,
|
||||
MacTerminalKind::Unknown,
|
||||
MacTerminalKind::Iterm2,
|
||||
] {
|
||||
let launcher = launch_command_for_macos_terminal(terminal, &shell_command);
|
||||
assert!(
|
||||
launcher.contains("/bin/bash -lc"),
|
||||
"{terminal:?} launcher must run under bash: {launcher}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_command_terminals_pass_snippet_to_bash_not_login_shell() {
|
||||
// Ghostty/Alacritty/WezTerm wrap via `open -na ... -e /bin/bash -lc`,
|
||||
// so the bash-specific snippet never reaches the login shell.
|
||||
let shell_command = super::paused_jcode_shell_command("/usr/local/bin/jcode");
|
||||
for terminal in [
|
||||
MacTerminalKind::Ghostty,
|
||||
MacTerminalKind::Alacritty,
|
||||
MacTerminalKind::WezTerm,
|
||||
] {
|
||||
let launcher = launch_command_for_macos_terminal(terminal, &shell_command);
|
||||
assert!(
|
||||
launcher.contains("/bin/bash -lc"),
|
||||
"{terminal:?} launcher must run under bash: {launcher}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_launch_shows_explicit_alignment_hint_first() {
|
||||
let state = SetupHintsState {
|
||||
launch_count: 1,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
|
||||
assert_eq!(
|
||||
hints.status_notice.as_deref(),
|
||||
Some("Tip: `/alignment centered` or Alt+C toggles alignment.")
|
||||
);
|
||||
|
||||
let (title, message) = hints.display_message.expect("expected display message");
|
||||
assert_eq!(title, "Alignment");
|
||||
assert!(message.contains("Alt+C"));
|
||||
assert!(message.contains("/alignment centered"));
|
||||
assert!(message.contains("left-aligned by default"));
|
||||
assert!(!message.contains("display.centered = true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_and_third_launches_include_alignment_tip() {
|
||||
let state = SetupHintsState {
|
||||
launch_count: 2,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
|
||||
assert_eq!(
|
||||
hints.status_notice.as_deref(),
|
||||
Some("Tip: Alt+C toggles left/center alignment.")
|
||||
);
|
||||
|
||||
let (title, message) = hints.display_message.expect("expected display message");
|
||||
assert_eq!(title, "Welcome");
|
||||
assert!(message.contains("Alt+C"));
|
||||
assert!(message.contains("/alignment centered"));
|
||||
assert!(message.contains("/alignment left"));
|
||||
assert!(message.contains("display.centered = true"));
|
||||
assert!(message.contains("Left-aligned mode is the default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launches_after_third_do_not_show_generic_alignment_tip() {
|
||||
let state = SetupHintsState {
|
||||
launch_count: 4,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
assert!(startup_hints_for_launch(&state).is_none());
|
||||
}
|
||||
|
||||
// Asserts the macOS-specific spawn notice text (`Cmd+;` etc.), so it only makes
|
||||
// sense on macOS. On other platforms the notice uses different chords/wording.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn first_three_launches_can_include_hotkey_notice_too() {
|
||||
let state = SetupHintsState {
|
||||
launch_count: 2,
|
||||
hotkey_configured: true,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
|
||||
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
|
||||
let (_, message) = hints.display_message.expect("expected display message");
|
||||
assert!(message.contains("Alt+C"));
|
||||
assert!(message.contains("Cmd+;"));
|
||||
// The notice should make clear the hotkey works globally, not just inside jcode.
|
||||
assert!(message.contains("system-wide"));
|
||||
// All three launch hotkeys should be mentioned.
|
||||
assert!(message.contains("Cmd+'"));
|
||||
assert!(message.contains("Cmd+Shift+'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolved_hotkeys_match_legacy_three() {
|
||||
// With no config, the resolver reproduces the historical three hotkeys.
|
||||
let resolved = launch_hotkeys::resolve_launch_hotkeys(
|
||||
&jcode_config_types::LaunchHotkeysConfig::default(),
|
||||
"/usr/local/bin/jcode",
|
||||
"/home/u/.jcode/hotkey/last_dir",
|
||||
"/home/u/.jcode/hotkey/last_repo",
|
||||
);
|
||||
let chords: Vec<&str> = resolved.iter().map(|r| r.chord.as_str()).collect();
|
||||
assert_eq!(chords, vec!["cmd+;", "cmd+'", "cmd+shift+'"]);
|
||||
|
||||
// Home launch passes no extra subcommand; self-dev passes `self-dev`.
|
||||
let home = launch_hotkeys::shell_command_for(&resolved[0], "/usr/local/bin/jcode");
|
||||
assert!(home.starts_with("cd \"$HOME\"; "));
|
||||
assert!(!home.contains("self-dev"));
|
||||
|
||||
let last_dir = launch_hotkeys::shell_command_for(&resolved[1], "/usr/local/bin/jcode");
|
||||
assert!(last_dir.contains("cat '/home/u/.jcode/hotkey/last_dir'"));
|
||||
assert!(last_dir.contains("cd \"$HOME\""));
|
||||
|
||||
let selfdev = launch_hotkeys::shell_command_for(&resolved[2], "/usr/local/bin/jcode");
|
||||
assert!(selfdev.contains("cat '/home/u/.jcode/hotkey/last_repo'"));
|
||||
assert!(selfdev.contains("'/usr/local/bin/jcode' 'self-dev';"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_repo_hotkey_cds_into_fixed_dir() {
|
||||
// A config-baked per-repo hotkey opens a fixed directory.
|
||||
let config = jcode_config_types::LaunchHotkeysConfig {
|
||||
enabled: Some(true),
|
||||
imported: true,
|
||||
entries: vec![jcode_config_types::LaunchHotkeyEntry {
|
||||
chord: "cmd+[".to_string(),
|
||||
dir: "/Users/jeremy/jcode-github".to_string(),
|
||||
label: "jcode-github".to_string(),
|
||||
self_dev: false,
|
||||
}],
|
||||
};
|
||||
let resolved = launch_hotkeys::resolve_launch_hotkeys(
|
||||
&config,
|
||||
"/usr/local/bin/jcode",
|
||||
"/home/u/.jcode/hotkey/last_dir",
|
||||
"/home/u/.jcode/hotkey/last_repo",
|
||||
);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].chord, "cmd+[");
|
||||
let cmd = launch_hotkeys::shell_command_for(&resolved[0], "/usr/local/bin/jcode");
|
||||
assert!(cmd.contains("/Users/jeremy/jcode-github"));
|
||||
assert!(cmd.contains("cd \"$HOME\""), "must keep a home fallback");
|
||||
assert!(!cmd.contains("self-dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_record_last_dir_skips_home_only() {
|
||||
use std::path::Path;
|
||||
let home = Path::new("/Users/jeremy");
|
||||
// Home itself is skipped (Cmd+; already covers home).
|
||||
assert!(!super::should_record_last_dir(home, Some(home)));
|
||||
// Any other project dir is recorded for Cmd+'.
|
||||
assert!(super::should_record_last_dir(
|
||||
Path::new("/Users/jeremy/projects/foo"),
|
||||
Some(home)
|
||||
));
|
||||
// With no known home, always record.
|
||||
assert!(super::should_record_last_dir(home, None));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn install_writes_executable_scripts_and_plan() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let resolved = launch_hotkeys::resolve_launch_hotkeys(
|
||||
&jcode_config_types::LaunchHotkeysConfig::default(),
|
||||
"/usr/local/bin/jcode",
|
||||
"/home/u/.jcode/hotkey/last_dir",
|
||||
"/home/u/.jcode/hotkey/last_repo",
|
||||
);
|
||||
let plan = super::write_hotkey_launch_scripts(
|
||||
dir.path(),
|
||||
MacTerminalKind::Ghostty,
|
||||
"/usr/local/bin/jcode",
|
||||
&resolved,
|
||||
)
|
||||
.expect("scripts should write");
|
||||
|
||||
// One plan entry per resolved hotkey, each pointing at an executable bash
|
||||
// script that exists on disk.
|
||||
assert_eq!(plan.len(), resolved.len());
|
||||
for entry in &plan {
|
||||
let path = std::path::Path::new(&entry.script);
|
||||
let body = std::fs::read_to_string(path).expect("script exists");
|
||||
assert!(body.starts_with("#!/bin/bash"));
|
||||
let mode = std::fs::metadata(path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o111, 0o111, "script should be executable");
|
||||
}
|
||||
|
||||
// Only the self-dev (3rd) script invokes the self-dev subcommand.
|
||||
let selfdev = std::fs::read_to_string(&plan[2].script).unwrap();
|
||||
assert!(selfdev.contains("self-dev"));
|
||||
let home = std::fs::read_to_string(&plan[0].script).unwrap();
|
||||
assert!(!home.contains("self-dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_hotkey_launch_agent_plist_uses_valid_xml_quotes() {
|
||||
let plist = mac_hotkey_launch_agent_plist(
|
||||
"/Applications/Jcode.app/Contents/MacOS/jcode",
|
||||
"/tmp/jcode-hotkey.out.log",
|
||||
"/tmp/jcode-hotkey.err.log",
|
||||
"ghostty",
|
||||
);
|
||||
|
||||
assert!(plist.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
|
||||
assert!(plist.contains("<plist version=\"1.0\">"));
|
||||
assert!(!plist.contains("\\\""));
|
||||
assert!(plist.contains("<string>setup-hotkey</string>"));
|
||||
assert!(plist.contains("<string>--listen-macos-hotkey</string>"));
|
||||
// The listener must load into the GUI (Aqua) session so it has a
|
||||
// window-server connection and can receive Carbon hotkey events.
|
||||
assert!(plist.contains("<key>LimitLoadToSessionType</key>"));
|
||||
assert!(plist.contains("<string>Aqua</string>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_jcode_shell_command_keeps_failures_visible() {
|
||||
let command = paused_jcode_shell_command("/tmp/jcode");
|
||||
assert!(command.contains("Press Enter to close"));
|
||||
assert!(command.contains("Jcode exited with status"));
|
||||
assert!(command.contains("jcode executable not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_user_gets_hotkey_install() {
|
||||
let state = SetupHintsState::default();
|
||||
assert_eq!(
|
||||
mac_hotkey_action_for_state(&state),
|
||||
MacHotkeyAction::Install
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_configured_user_gets_migrated_on_update() {
|
||||
// Configured before the version field existed -> version defaults to 0.
|
||||
let state = SetupHintsState {
|
||||
hotkey_configured: true,
|
||||
hotkey_dismissed: true,
|
||||
hotkey_listener_version: 0,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
mac_hotkey_action_for_state(&state),
|
||||
MacHotkeyAction::Migrate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_version_user_is_left_alone() {
|
||||
let state = SetupHintsState {
|
||||
hotkey_configured: true,
|
||||
hotkey_dismissed: true,
|
||||
hotkey_listener_version: HOTKEY_LISTENER_VERSION,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
assert_eq!(mac_hotkey_action_for_state(&state), MacHotkeyAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_listener_version_user_gets_migrated_on_update() {
|
||||
// A user who already installed an earlier listener version (e.g. the v1
|
||||
// run-loop-only listener that still never fired) must be re-migrated to the
|
||||
// current listener on update.
|
||||
for old_version in 0..HOTKEY_LISTENER_VERSION {
|
||||
let state = SetupHintsState {
|
||||
hotkey_configured: true,
|
||||
hotkey_dismissed: true,
|
||||
hotkey_listener_version: old_version,
|
||||
..SetupHintsState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
mac_hotkey_action_for_state(&state),
|
||||
MacHotkeyAction::Migrate,
|
||||
"listener version {old_version} should be migrated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_terminal_notice_only_fires_for_default_terminal_app() {
|
||||
let mut state = SetupHintsState::default();
|
||||
let hints = macos_terminal_notice(&mut state, MacTerminalKind::AppleTerminal)
|
||||
.expect("Terminal.app should produce a notice");
|
||||
|
||||
assert_eq!(
|
||||
hints.status_notice.as_deref(),
|
||||
Some("Tip: Terminal.app renders jcode poorly. Try Ghostty, iTerm2, or Alacritty.")
|
||||
);
|
||||
let (title, message) = hints.display_message.expect("expected display message");
|
||||
assert_eq!(title, "Terminal");
|
||||
assert!(message.contains("Terminal.app renders jcode poorly"));
|
||||
assert!(message.contains("Ghostty"));
|
||||
// It is a plain notice, not an AI handoff prompt.
|
||||
assert!(hints.auto_send_message.is_none());
|
||||
// The nudge is marked handled so it only ever shows once.
|
||||
assert!(state.mac_ghostty_guided);
|
||||
assert!(state.mac_ghostty_dismissed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_terminal_notice_silent_for_modern_terminals() {
|
||||
for terminal in [
|
||||
MacTerminalKind::Ghostty,
|
||||
MacTerminalKind::Iterm2,
|
||||
MacTerminalKind::WezTerm,
|
||||
MacTerminalKind::Warp,
|
||||
MacTerminalKind::Alacritty,
|
||||
MacTerminalKind::Vscode,
|
||||
MacTerminalKind::Unknown,
|
||||
] {
|
||||
let mut state = SetupHintsState::default();
|
||||
assert!(
|
||||
macos_terminal_notice(&mut state, terminal).is_none(),
|
||||
"{terminal:?} should not be nudged"
|
||||
);
|
||||
// Even when silent, the nudge is marked handled so we never re-check it.
|
||||
assert!(state.mac_ghostty_guided);
|
||||
assert!(state.mac_ghostty_dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudge_budget_caps_at_max_and_persists() {
|
||||
let mut state = SetupHintsState::default();
|
||||
assert_eq!(state.terminal_nudge_count, 0);
|
||||
|
||||
for shown in 1..=MAX_TERMINAL_NUDGES {
|
||||
assert!(
|
||||
state.nudge_budget_remaining(),
|
||||
"should still allow nudge before #{shown}"
|
||||
);
|
||||
state.terminal_nudge_count = shown;
|
||||
}
|
||||
|
||||
// After MAX_TERMINAL_NUDGES, we stop asking even without an explicit dismiss.
|
||||
assert_eq!(state.terminal_nudge_count, MAX_TERMINAL_NUDGES);
|
||||
assert!(!state.nudge_budget_remaining());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_falls_back_to_bak_when_primary_missing() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("setup_hints.json");
|
||||
let bak = dir.path().join("setup_hints.bak");
|
||||
|
||||
std::fs::write(&bak, r#"{"launch_count":42}"#).unwrap();
|
||||
|
||||
// Primary file missing: must recover launch_count from the .bak instead of
|
||||
// resetting to default (which would re-trigger first-run onboarding).
|
||||
let loaded = SetupHintsState::load_from(&path);
|
||||
assert_eq!(loaded.launch_count, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_falls_back_to_bak_when_primary_corrupt_without_inline_recovery() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("setup_hints.json");
|
||||
let bak = dir.path().join("setup_hints.bak");
|
||||
|
||||
std::fs::write(&path, b"{not json").unwrap();
|
||||
std::fs::write(&bak, r#"{"launch_count":7}"#).unwrap();
|
||||
|
||||
let loaded = SetupHintsState::load_from(&path);
|
||||
assert_eq!(loaded.launch_count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_defaults_when_both_missing() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("setup_hints.json");
|
||||
let loaded = SetupHintsState::load_from(&path);
|
||||
assert_eq!(loaded.launch_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_hint_decision_warns_only_when_conflicts_change() {
|
||||
// No conflicts ever: empty == empty => stay silent.
|
||||
assert_eq!(
|
||||
conflict_hint_decision("", ""),
|
||||
ConflictHintDecision::Unchanged
|
||||
);
|
||||
|
||||
// New conflicts where there were none: warn.
|
||||
assert_eq!(
|
||||
conflict_hint_decision("keybindings.model_switch_next|ctrl+tab|ctrl+tab", ""),
|
||||
ConflictHintDecision::Warn
|
||||
);
|
||||
|
||||
// Same conflicts as last time: stay silent.
|
||||
let sig = "keybindings.model_switch_next|ctrl+tab|ctrl+tab";
|
||||
assert_eq!(
|
||||
conflict_hint_decision(sig, sig),
|
||||
ConflictHintDecision::Unchanged
|
||||
);
|
||||
|
||||
// Conflicts resolved since last time (had some, now none): update silently.
|
||||
assert_eq!(
|
||||
conflict_hint_decision("", sig),
|
||||
ConflictHintDecision::ResolvedSilently
|
||||
);
|
||||
|
||||
// Conflict set changed (different conflicts): warn again.
|
||||
assert_eq!(
|
||||
conflict_hint_decision("keybindings.scroll_up|ctrl+k|ctrl+k", sig),
|
||||
ConflictHintDecision::Warn
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keymap_conflict_hint_full_path_debounces_and_persists_signature() {
|
||||
use crate::keymap::source::{DiscoveredBinding, KeySource};
|
||||
use crate::keymap::{KeyChord, KeymapSnapshot};
|
||||
use jcode_config_types::KeybindingsConfig;
|
||||
|
||||
fn snapshot(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
|
||||
KeymapSnapshot {
|
||||
version: 1,
|
||||
captured_at: "0".to_string(),
|
||||
os: "macos".to_string(),
|
||||
terminal: "Ghostty".to_string(),
|
||||
terminal_version: "1.3.1".to_string(),
|
||||
bindings,
|
||||
}
|
||||
}
|
||||
fn term(keys: &str, action: &str) -> DiscoveredBinding {
|
||||
DiscoveredBinding {
|
||||
chord: KeyChord::parse(keys).unwrap(),
|
||||
source: KeySource::Terminal,
|
||||
action: action.to_string(),
|
||||
raw: format!("{keys}={action}"),
|
||||
tool: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = KeybindingsConfig::default();
|
||||
let mut state = SetupHintsState::default();
|
||||
|
||||
// 1) First time with a real conflict: warn + state changes.
|
||||
let conflicting = snapshot(vec![term("ctrl+tab", "next_tab")]);
|
||||
let (hint, changed) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state);
|
||||
assert!(hint.is_some(), "should warn on first conflict");
|
||||
assert!(changed, "state signature should be recorded");
|
||||
let (title, body) = hint.unwrap().display_message.unwrap();
|
||||
assert_eq!(title, "Keybindings");
|
||||
assert!(body.contains("keybindings.model_switch_next"));
|
||||
assert!(!state.keymap_conflict_signature.is_empty());
|
||||
|
||||
// 2) Same conflict again: debounced, no state change.
|
||||
let (hint2, changed2) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state);
|
||||
assert!(hint2.is_none(), "same conflict set must not re-warn");
|
||||
assert!(!changed2, "no state change when nothing changed");
|
||||
|
||||
// 3) Conflict resolved (clean snapshot): silent, but signature cleared.
|
||||
let clean = snapshot(vec![term("cmd+t", "new_tab")]);
|
||||
let (hint3, changed3) = keymap_conflict_hint_for(&cfg, &clean, &mut state);
|
||||
assert!(hint3.is_none(), "resolved conflicts show nothing");
|
||||
assert!(changed3, "signature should be cleared");
|
||||
assert!(state.keymap_conflict_signature.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_safe_notice_shows_once_then_debounces() {
|
||||
let mut state = SetupHintsState::default();
|
||||
|
||||
// First launch in a fragile terminal: disclose the tradeoff and persist.
|
||||
let (hint, changed) = glyph_safe_notice_for(true, &mut state);
|
||||
assert!(
|
||||
hint.is_some(),
|
||||
"should disclose glyph-safe mode on first launch"
|
||||
);
|
||||
assert!(changed, "state should be marked shown");
|
||||
assert!(state.glyph_safe_notice_shown);
|
||||
let (title, body) = hint.unwrap().display_message.unwrap();
|
||||
assert_eq!(title, "Display");
|
||||
assert!(body.contains("quantizes colors"));
|
||||
assert!(body.contains("JCODE_GLYPH_SAFE_MODE=off"));
|
||||
|
||||
// Subsequent launches: debounced, no repeat.
|
||||
let (hint2, changed2) = glyph_safe_notice_for(true, &mut state);
|
||||
assert!(hint2.is_none(), "must not re-disclose on later launches");
|
||||
assert!(!changed2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_safe_notice_silent_on_robust_terminals() {
|
||||
let mut state = SetupHintsState::default();
|
||||
let (hint, changed) = glyph_safe_notice_for(false, &mut state);
|
||||
assert!(
|
||||
hint.is_none(),
|
||||
"no disclosure when glyph-safe mode is inactive"
|
||||
);
|
||||
assert!(!changed);
|
||||
assert!(!state.glyph_safe_notice_shown);
|
||||
}
|
||||
|
||||
fn row(chord: &str, label: &str, self_dev: bool) -> LaunchHotkeyRow {
|
||||
LaunchHotkeyRow {
|
||||
chord: chord.to_string(),
|
||||
display: keymap::KeyChord::parse(chord)
|
||||
.map(|c| c.display_symbols())
|
||||
.unwrap_or_else(|| chord.to_string()),
|
||||
label: label.to_string(),
|
||||
cwd_display: format!("/repos/{label}"),
|
||||
self_dev,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hotkey_notice_lists_all_unlearned_bindings() {
|
||||
let rows = vec![
|
||||
row("cmd+;", "home", false),
|
||||
row("cmd+'", "last project", false),
|
||||
row("cmd+shift+'", "self-dev", true),
|
||||
];
|
||||
let usage = std::collections::HashMap::new();
|
||||
let lines = launch_hotkey_notice_lines(&rows, &usage, 1).expect("should show all bindings");
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].starts_with("⌘; → home (/repos/home)"));
|
||||
assert!(lines[2].ends_with("[self-dev]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hotkey_notice_hides_individually_learned_bindings() {
|
||||
let rows = vec![
|
||||
row("cmd+;", "home", false),
|
||||
row("cmd+'", "last project", false),
|
||||
];
|
||||
let mut usage = std::collections::HashMap::new();
|
||||
// cmd+; used enough to be considered learned; cmd+' still new.
|
||||
usage.insert("cmd+;".to_string(), LAUNCH_HOTKEY_LEARNED_USES);
|
||||
let lines = launch_hotkey_notice_lines(&rows, &usage, 3).expect("one binding still new");
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("⌘' → last project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hotkey_notice_stops_once_learned_and_experienced() {
|
||||
let rows = vec![
|
||||
row("cmd+;", "home", false),
|
||||
row("cmd+'", "last project", false),
|
||||
];
|
||||
let mut usage = std::collections::HashMap::new();
|
||||
usage.insert("cmd+;".to_string(), LAUNCH_HOTKEY_LEARNED_USES);
|
||||
// Learned at least one binding AND launched enough overall -> stop entirely,
|
||||
// even though cmd+' was never used.
|
||||
assert!(
|
||||
launch_hotkey_notice_lines(&rows, &usage, LAUNCH_HOTKEY_NOTICE_MIN_LAUNCHES_TO_STOP)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hotkey_notice_keeps_showing_for_new_user_with_many_launches() {
|
||||
// Many launches but no binding learned yet: keep showing so they can adopt it.
|
||||
let rows = vec![row("cmd+;", "home", false)];
|
||||
let usage = std::collections::HashMap::new();
|
||||
let lines =
|
||||
launch_hotkey_notice_lines(&rows, &usage, 50).expect("never learned -> keep showing");
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Config-driven global launch hotkeys on Windows.
|
||||
//!
|
||||
//! Windows registers global hotkeys through the Win32 `RegisterHotKey` API. We
|
||||
//! keep the existing delivery mechanism (a hidden PowerShell listener started
|
||||
//! at login) but generate it from the shared `[launch_hotkeys]` config instead
|
||||
//! of hard-coding a single Alt+; binding, so per-repo hotkeys work on Windows
|
||||
//! the same way they do on macOS and Linux.
|
||||
//!
|
||||
//! This module is the **pure** layer, mirroring `linux_niri`/`linux_env`:
|
||||
//!
|
||||
//! * [`chord_to_win32`] maps a jcode [`KeyChord`] onto `RegisterHotKey`
|
||||
//! modifier flags plus a virtual-key code. jcode's `cmd` modifier maps to
|
||||
//! **Alt** on Windows (matching the historical Alt+; hotkey): most Win+ key
|
||||
//! combos are reserved by the OS (Win+; opens the emoji panel), so mapping
|
||||
//! cmd -> Win would produce hotkeys that never fire.
|
||||
//! * [`render_windows_listener_ps1`] renders the entire listener script from
|
||||
//! the resolved entries. Dynamic targets (`$LAST_DIR`/`$LAST_REPO`) are read
|
||||
//! from their files at keypress time, so "last project" tracks new launches
|
||||
//! without restarting the listener.
|
||||
//!
|
||||
//! The install glue (writing the script, startup shortcut, process restart)
|
||||
//! stays in `windows_setup.rs`.
|
||||
|
||||
use crate::keymap::KeyChord;
|
||||
|
||||
/// `RegisterHotKey` modifier flags.
|
||||
const MOD_ALT: u32 = 0x0001;
|
||||
const MOD_CONTROL: u32 = 0x0002;
|
||||
const MOD_SHIFT: u32 = 0x0004;
|
||||
|
||||
/// One launch hotkey resolved for the Windows listener.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WindowsHotkey {
|
||||
pub chord: KeyChord,
|
||||
/// Configured directory target: an absolute path or one of the sentinels
|
||||
/// `$HOME`, `$LAST_DIR`, `$LAST_REPO` (resolved at keypress time).
|
||||
pub dir: String,
|
||||
/// Short human label, e.g. the repo's directory name.
|
||||
pub label: String,
|
||||
pub self_dev: bool,
|
||||
}
|
||||
|
||||
/// Map a chord onto `(modifier_flags, virtual_key_code)` for `RegisterHotKey`.
|
||||
/// Returns `None` for keys with no stable VK code. jcode `cmd` maps to Alt
|
||||
/// (see module docs); a chord that is *both* cmd and alt still collapses onto
|
||||
/// a single MOD_ALT, which is the closest expressible binding.
|
||||
pub(crate) fn chord_to_win32(chord: &KeyChord) -> Option<(u32, u32)> {
|
||||
let vk = key_to_vk(&chord.key)?;
|
||||
let mut mods = 0u32;
|
||||
if chord.cmd || chord.alt {
|
||||
mods |= MOD_ALT;
|
||||
}
|
||||
if chord.ctrl {
|
||||
mods |= MOD_CONTROL;
|
||||
}
|
||||
if chord.shift {
|
||||
mods |= MOD_SHIFT;
|
||||
}
|
||||
if mods == 0 {
|
||||
// An unmodified global hotkey would swallow plain typing; refuse it.
|
||||
return None;
|
||||
}
|
||||
Some((mods, vk))
|
||||
}
|
||||
|
||||
/// Translate a canonical jcode key token into a Win32 virtual-key code.
|
||||
fn key_to_vk(key: &str) -> Option<u32> {
|
||||
let vk = match key {
|
||||
";" => 0xBA, // VK_OEM_1
|
||||
"=" => 0xBB, // VK_OEM_PLUS
|
||||
"," => 0xBC, // VK_OEM_COMMA
|
||||
"-" => 0xBD, // VK_OEM_MINUS
|
||||
"." => 0xBE, // VK_OEM_PERIOD
|
||||
"/" => 0xBF, // VK_OEM_2
|
||||
"`" => 0xC0, // VK_OEM_3
|
||||
"[" => 0xDB, // VK_OEM_4
|
||||
"\\" => 0xDC, // VK_OEM_5
|
||||
"]" => 0xDD, // VK_OEM_6
|
||||
"'" => 0xDE, // VK_OEM_7
|
||||
"space" => 0x20,
|
||||
"left" => 0x25,
|
||||
"up" => 0x26,
|
||||
"right" => 0x27,
|
||||
"down" => 0x28,
|
||||
"insert" => 0x2D,
|
||||
"delete" => 0x2E,
|
||||
"home" => 0x24,
|
||||
"end" => 0x23,
|
||||
"pageup" => 0x21,
|
||||
"pagedown" => 0x22,
|
||||
other => {
|
||||
let mut chars = other.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
||||
if c.is_ascii_alphabetic() {
|
||||
return Some(c.to_ascii_uppercase() as u32);
|
||||
}
|
||||
if c.is_ascii_digit() {
|
||||
return Some(c as u32);
|
||||
}
|
||||
}
|
||||
// f1..f24 -> VK_F1 (0x70) ..
|
||||
if let Some(rest) = other.strip_prefix('f')
|
||||
&& let Ok(n) = rest.parse::<u32>()
|
||||
&& (1..=24).contains(&n)
|
||||
{
|
||||
return Some(0x70 + n - 1);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(vk)
|
||||
}
|
||||
|
||||
/// User-facing chord rendering for Windows notices: jcode's `cmd` modifier
|
||||
/// shows as `Alt` because that is what [`chord_to_win32`] registers.
|
||||
pub(crate) fn display_windows(chord: &KeyChord) -> String {
|
||||
let mut mapped = chord.clone();
|
||||
if mapped.cmd {
|
||||
mapped.cmd = false;
|
||||
mapped.alt = true;
|
||||
}
|
||||
mapped.display()
|
||||
}
|
||||
|
||||
/// Escape a string for a single-quoted PowerShell literal.
|
||||
fn ps_quote(input: &str) -> String {
|
||||
format!("'{}'", input.replace('\'', "''"))
|
||||
}
|
||||
|
||||
/// PowerShell expression resolving one entry's working directory at keypress
|
||||
/// time, with a `$HOME` fallback for missing/stale targets.
|
||||
fn ps_dir_expr(dir: &str, last_dir_file: &str, last_repo_file: &str) -> String {
|
||||
match dir {
|
||||
"$HOME" => "(Resolve-JcodeDir $null)".to_string(),
|
||||
"$LAST_DIR" => format!("(Resolve-JcodeDir {})", ps_quote(last_dir_file)),
|
||||
"$LAST_REPO" => format!("(Resolve-JcodeDir {})", ps_quote(last_repo_file)),
|
||||
path => format!("(Resolve-JcodeFixedDir {})", ps_quote(path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full hotkey-listener PowerShell script.
|
||||
///
|
||||
/// `launch_exe`/`launch_args` describe the terminal command that opens jcode
|
||||
/// (e.g. `wt.exe` + profile args, or `alacritty -e jcode`); `{DIR}` never
|
||||
/// appears in them because the working directory is passed via
|
||||
/// `Start-Process -WorkingDirectory`. Entries whose chord cannot be expressed
|
||||
/// are skipped. Returns `None` when nothing is registerable.
|
||||
pub(crate) fn render_windows_listener_ps1(
|
||||
entries: &[WindowsHotkey],
|
||||
launch_exe: &str,
|
||||
launch_args_for: impl Fn(&WindowsHotkey) -> String,
|
||||
last_dir_file: &str,
|
||||
last_repo_file: &str,
|
||||
) -> Option<String> {
|
||||
let mut registrations = String::new();
|
||||
let mut dispatch = String::new();
|
||||
let mut count = 0usize;
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let Some((mods, vk)) = chord_to_win32(&entry.chord) else {
|
||||
continue;
|
||||
};
|
||||
count += 1;
|
||||
// Ids only need to be process-unique; derive from the entry index.
|
||||
let id = 0x4A00 + index; // "J" namespace
|
||||
let label = display_windows(&entry.chord);
|
||||
registrations.push_str(&format!(
|
||||
"Register-JcodeHotkey -Id 0x{id:X} -Mods 0x{mods:X} -Vk 0x{vk:X} -Label {label}\n",
|
||||
label = ps_quote(&format!("{label} ({})", entry.label)),
|
||||
));
|
||||
let dir_expr = ps_dir_expr(&entry.dir, last_dir_file, last_repo_file);
|
||||
let args = launch_args_for(entry);
|
||||
let args_part = if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" -ArgumentList {}", ps_quote(&args))
|
||||
};
|
||||
dispatch.push_str(&format!(
|
||||
" 0x{id:X} {{ Start-Process {exe}{args_part} -WorkingDirectory {dir_expr} }}\n",
|
||||
exe = ps_quote(launch_exe),
|
||||
));
|
||||
}
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
r#"# jcode global launch hotkey listener
|
||||
# Auto-generated by jcode setup-hotkey from [launch_hotkeys] config. Runs at
|
||||
# login via a startup shortcut. Do not edit; re-run `jcode setup-hotkey`.
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class HotKeyHelper {{
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MSG {{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int pt_x;
|
||||
public int pt_y;
|
||||
}}
|
||||
}}
|
||||
"@
|
||||
|
||||
$MOD_NOREPEAT = 0x4000
|
||||
$WM_HOTKEY = 0x0312
|
||||
$script:RegisteredIds = @()
|
||||
|
||||
function Register-JcodeHotkey {{
|
||||
param([int]$Id, [uint32]$Mods, [uint32]$Vk, [string]$Label)
|
||||
if ([HotKeyHelper]::RegisterHotKey([IntPtr]::Zero, $Id, $Mods -bor $MOD_NOREPEAT, $Vk)) {{
|
||||
$script:RegisteredIds += $Id
|
||||
}} else {{
|
||||
Write-Warning "jcode: failed to register hotkey $Label (already claimed?)"
|
||||
}}
|
||||
}}
|
||||
|
||||
# Resolve a dynamic launch dir from a tracking file, falling back to $HOME.
|
||||
function Resolve-JcodeDir {{
|
||||
param([string]$File)
|
||||
if ($File) {{
|
||||
try {{
|
||||
$dir = (Get-Content -LiteralPath $File -ErrorAction Stop | Select-Object -First 1).Trim()
|
||||
if ($dir -and (Test-Path -LiteralPath $dir -PathType Container)) {{ return $dir }}
|
||||
}} catch {{}}
|
||||
}}
|
||||
return $env:USERPROFILE
|
||||
}}
|
||||
|
||||
# A baked absolute dir, falling back to $HOME when it no longer exists.
|
||||
function Resolve-JcodeFixedDir {{
|
||||
param([string]$Dir)
|
||||
if ($Dir -and (Test-Path -LiteralPath $Dir -PathType Container)) {{ return $Dir }}
|
||||
return $env:USERPROFILE
|
||||
}}
|
||||
|
||||
{registrations}
|
||||
if ($script:RegisteredIds.Count -eq 0) {{
|
||||
Write-Error "jcode: no launch hotkeys could be registered"
|
||||
exit 1
|
||||
}}
|
||||
|
||||
try {{
|
||||
$msg = New-Object HotKeyHelper+MSG
|
||||
while ([HotKeyHelper]::GetMessage([ref]$msg, [IntPtr]::Zero, $WM_HOTKEY, $WM_HOTKEY) -ne 0) {{
|
||||
if ($msg.message -ne $WM_HOTKEY) {{ continue }}
|
||||
switch ($msg.wParam.ToInt32()) {{
|
||||
{dispatch} }}
|
||||
}}
|
||||
}} finally {{
|
||||
foreach ($id in $script:RegisteredIds) {{
|
||||
[HotKeyHelper]::UnregisterHotKey([IntPtr]::Zero, $id) | Out-Null
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chord(s: &str) -> KeyChord {
|
||||
KeyChord::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn hk(chord_str: &str, dir: &str, label: &str, self_dev: bool) -> WindowsHotkey {
|
||||
WindowsHotkey {
|
||||
chord: chord(chord_str),
|
||||
dir: dir.to_string(),
|
||||
label: label.to_string(),
|
||||
self_dev,
|
||||
}
|
||||
}
|
||||
|
||||
fn args_for(entry: &WindowsHotkey) -> String {
|
||||
if entry.self_dev {
|
||||
r#"-e "C:\jcode.exe" self-dev"#.to_string()
|
||||
} else {
|
||||
r#"-e "C:\jcode.exe""#.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_maps_to_alt_and_punctuation_maps_to_oem_vks() {
|
||||
assert_eq!(chord_to_win32(&chord("cmd+;")).unwrap(), (MOD_ALT, 0xBA));
|
||||
assert_eq!(
|
||||
chord_to_win32(&chord("cmd+shift+'")).unwrap(),
|
||||
(MOD_ALT | MOD_SHIFT, 0xDE)
|
||||
);
|
||||
assert_eq!(chord_to_win32(&chord("cmd+[")).unwrap(), (MOD_ALT, 0xDB));
|
||||
assert_eq!(chord_to_win32(&chord("cmd+]")).unwrap(), (MOD_ALT, 0xDD));
|
||||
assert_eq!(chord_to_win32(&chord("cmd+\\")).unwrap(), (MOD_ALT, 0xDC));
|
||||
assert_eq!(
|
||||
chord_to_win32(&chord("ctrl+alt+k")).unwrap(),
|
||||
(MOD_ALT | MOD_CONTROL, 'K' as u32)
|
||||
);
|
||||
assert_eq!(chord_to_win32(&chord("alt+f5")).unwrap(), (MOD_ALT, 0x74));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unmodified_and_unmappable_chords() {
|
||||
// A bare key must never become a global hotkey.
|
||||
assert!(chord_to_win32(&chord("k")).is_none());
|
||||
assert!(chord_to_win32(&chord("cmd+scrolllock")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_windows_renders_cmd_as_alt() {
|
||||
assert_eq!(display_windows(&chord("cmd+;")), "Alt+;");
|
||||
assert_eq!(display_windows(&chord("cmd+shift+'")), "Alt+Shift+'");
|
||||
assert_eq!(display_windows(&chord("ctrl+k")), "Ctrl+K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_script_registers_each_entry_and_dispatches_dirs() {
|
||||
let entries = vec![
|
||||
hk("cmd+;", "C:\\Users\\u\\jcode", "jcode", false),
|
||||
hk("cmd+'", "$HOME", "home", false),
|
||||
hk("cmd+shift+'", "$LAST_REPO", "self-dev", true),
|
||||
];
|
||||
let script = render_windows_listener_ps1(
|
||||
&entries,
|
||||
"wt.exe",
|
||||
args_for,
|
||||
"C:\\Users\\u\\.jcode\\hotkey\\last_dir",
|
||||
"C:\\Users\\u\\.jcode\\hotkey\\last_repo",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Three registrations with distinct ids.
|
||||
assert_eq!(script.matches("Register-JcodeHotkey").count(), 3 + 1); // 3 calls + fn def
|
||||
assert!(script.contains("-Id 0x4A00"));
|
||||
assert!(script.contains("-Id 0x4A01"));
|
||||
assert!(script.contains("-Id 0x4A02"));
|
||||
|
||||
// Fixed dir, home fallback, and dynamic repo file all present.
|
||||
assert!(script.contains("Resolve-JcodeFixedDir 'C:\\Users\\u\\jcode'"));
|
||||
assert!(script.contains("Resolve-JcodeDir $null"));
|
||||
assert!(script.contains("Resolve-JcodeDir 'C:\\Users\\u\\.jcode\\hotkey\\last_repo'"));
|
||||
|
||||
// Self-dev entry passes the subcommand; others do not.
|
||||
assert_eq!(script.matches("self-dev").count(), 2); // label + args
|
||||
assert!(script.contains("Start-Process 'wt.exe'"));
|
||||
assert!(script.contains("-WorkingDirectory"));
|
||||
|
||||
// Cleanup unregisters everything.
|
||||
assert!(script.contains("UnregisterHotKey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_script_skips_unmappable_entries() {
|
||||
let entries = vec![
|
||||
hk("cmd+scrolllock", "$HOME", "bad", false),
|
||||
hk("cmd+;", "$HOME", "home", false),
|
||||
];
|
||||
let script = render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").unwrap();
|
||||
assert!(
|
||||
script.contains("-Id 0x4A01"),
|
||||
"kept entry keeps its slot id"
|
||||
);
|
||||
assert!(!script.contains("-Id 0x4A00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_script_none_when_nothing_registerable() {
|
||||
let entries = vec![hk("cmd+scrolllock", "$HOME", "bad", false)];
|
||||
assert!(render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").is_none());
|
||||
assert!(render_windows_listener_ps1(&[], "wt.exe", args_for, "", "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_quoting_escapes_single_quotes() {
|
||||
assert_eq!(ps_quote("it's"), "'it''s'");
|
||||
let entries = vec![hk("cmd+;", "C:\\Users\\o'brien\\proj", "o'brien", false)];
|
||||
let script = render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").unwrap();
|
||||
assert!(script.contains("'C:\\Users\\o''brien\\proj'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
use super::{SetupHintsState, StartupHints, read_choice};
|
||||
use crate::windows_hotkeys::{self, WindowsHotkey};
|
||||
use anyhow::Result;
|
||||
use jcode_storage as storage;
|
||||
use std::io::{self, Write};
|
||||
|
||||
fn detect_terminal() -> &'static str {
|
||||
if std::env::var("WT_SESSION").is_ok() {
|
||||
"windows-terminal"
|
||||
} else if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
|
||||
"wezterm"
|
||||
} else if std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
|
||||
"alacritty"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn is_alacritty_installed() -> bool {
|
||||
std::process::Command::new("where")
|
||||
.arg("alacritty")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_winget_available() -> bool {
|
||||
std::process::Command::new("where")
|
||||
.arg("winget")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn find_alacritty_path() -> Option<String> {
|
||||
let candidates = [
|
||||
r"C:\Program Files\Alacritty\alacritty.exe",
|
||||
r"C:\Program Files (x86)\Alacritty\alacritty.exe",
|
||||
];
|
||||
for c in &candidates {
|
||||
if std::path::Path::new(c).exists() {
|
||||
return Some(c.to_string());
|
||||
}
|
||||
}
|
||||
if let Ok(local) = std::env::var("LOCALAPPDATA") {
|
||||
let p = format!(r"{}\Microsoft\WinGet\Links\alacritty.exe", local);
|
||||
if std::path::Path::new(&p).exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let output = std::process::Command::new("where")
|
||||
.arg("alacritty")
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if let Some(line) = stdout.lines().next() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the `[launch_hotkeys]` config into Windows listener entries.
|
||||
/// Empty config reproduces the built-in three hotkeys, matching macOS/Linux.
|
||||
fn resolve_windows_hotkeys() -> Vec<WindowsHotkey> {
|
||||
let config = super::load_launch_hotkeys_config();
|
||||
let exe_path = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| "jcode".to_string());
|
||||
let last_dir = super::mac_hotkey_last_dir_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let last_repo = super::mac_hotkey_last_repo_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
crate::launch_hotkeys::resolve_launch_hotkeys(&config, &exe_path, &last_dir, &last_repo)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let chord = crate::keymap::KeyChord::parse(&entry.chord)?;
|
||||
Some(WindowsHotkey {
|
||||
chord,
|
||||
dir: entry.dir,
|
||||
self_dev: entry.args.iter().any(|a| a == "self-dev"),
|
||||
label: entry.label,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn create_hotkey_shortcut(use_alacritty: bool) -> Result<()> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe_path = exe.to_string_lossy();
|
||||
|
||||
let entries = resolve_windows_hotkeys();
|
||||
let last_dir = super::mac_hotkey_last_dir_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let last_repo = super::mac_hotkey_last_repo_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (launch_exe, launch_args_for): (String, Box<dyn Fn(&WindowsHotkey) -> String>) =
|
||||
if use_alacritty {
|
||||
let alacritty_path = find_alacritty_path().unwrap_or_else(|| "alacritty".to_string());
|
||||
let exe = exe_path.to_string();
|
||||
(
|
||||
alacritty_path,
|
||||
Box::new(move |hk: &WindowsHotkey| {
|
||||
if hk.self_dev {
|
||||
format!("-e \"{exe}\" self-dev")
|
||||
} else {
|
||||
format!("-e \"{exe}\"")
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
let exe = exe_path.to_string();
|
||||
(
|
||||
"wt.exe".to_string(),
|
||||
Box::new(move |hk: &WindowsHotkey| {
|
||||
if hk.self_dev {
|
||||
format!("-p \"Command Prompt\" \"{exe}\" self-dev")
|
||||
} else {
|
||||
format!("-p \"Command Prompt\" \"{exe}\"")
|
||||
}
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let Some(ps1_content) = windows_hotkeys::render_windows_listener_ps1(
|
||||
&entries,
|
||||
&launch_exe,
|
||||
|hk| launch_args_for(hk),
|
||||
&last_dir,
|
||||
&last_repo,
|
||||
) else {
|
||||
anyhow::bail!("no registerable launch hotkeys in config");
|
||||
};
|
||||
|
||||
let hotkey_dir = storage::jcode_dir()?.join("hotkey");
|
||||
std::fs::create_dir_all(&hotkey_dir)?;
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-Process powershell, pwsh -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*jcode-hotkey*' } | Stop-Process -Force -ErrorAction SilentlyContinue",
|
||||
])
|
||||
.output();
|
||||
|
||||
let ps1_path = hotkey_dir.join("jcode-hotkey.ps1");
|
||||
std::fs::write(&ps1_path, &ps1_content)?;
|
||||
|
||||
let startup_dir = format!(
|
||||
"{}\\Microsoft\\Windows\\Start Menu\\Programs\\Startup",
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| "C:\\Users\\Default\\AppData\\Roaming".into())
|
||||
);
|
||||
|
||||
let vbs_path = hotkey_dir.join("jcode-hotkey-launcher.vbs");
|
||||
let vbs_content = format!(
|
||||
"Set objShell = CreateObject(\"WScript.Shell\")\nobjShell.Run \"powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"\"{}\"\"\", 0, False\n",
|
||||
ps1_path.to_string_lossy()
|
||||
);
|
||||
std::fs::write(&vbs_path, &vbs_content)?;
|
||||
|
||||
let create_startup_lnk = format!(
|
||||
r#"
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut("{startup_dir}\jcode-hotkey.lnk")
|
||||
$shortcut.TargetPath = "wscript.exe"
|
||||
$shortcut.Arguments = '"{vbs_path}"'
|
||||
$shortcut.Description = "jcode Alt+; hotkey listener"
|
||||
$shortcut.WindowStyle = 7
|
||||
$shortcut.Save()
|
||||
Write-Output "OK"
|
||||
"#,
|
||||
startup_dir = startup_dir,
|
||||
vbs_path = vbs_path.to_string_lossy(),
|
||||
);
|
||||
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &create_startup_lnk])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to create startup shortcut: {}", stderr.trim());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.contains("OK") {
|
||||
anyhow::bail!("Startup shortcut creation did not confirm success");
|
||||
}
|
||||
|
||||
let start_output = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
&format!(
|
||||
"Start-Process wscript.exe -ArgumentList '\"{}\"' -WindowStyle Hidden",
|
||||
vbs_path.to_string_lossy()
|
||||
),
|
||||
])
|
||||
.output();
|
||||
|
||||
if let Err(e) = start_output {
|
||||
eprintln!(
|
||||
" \x1b[33m⚠\x1b[0m Could not start hotkey listener now: {}",
|
||||
e
|
||||
);
|
||||
eprintln!(" It will start automatically on next login.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the TUI startup notice for the Windows launch hotkeys (or `None` when
|
||||
/// there is nothing to show). Mirrors the macOS/Linux notices with Alt-style
|
||||
/// chords. Only shown once the listener is configured, since Windows needs the
|
||||
/// interactive `jcode setup-hotkey` flow to install it.
|
||||
pub(super) fn windows_launch_hotkeys_notice(state: &SetupHintsState) -> Option<StartupHints> {
|
||||
if !state.hotkey_configured {
|
||||
return None;
|
||||
}
|
||||
let config = super::load_launch_hotkeys_config();
|
||||
if config.enabled == Some(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_dir = super::mac_hotkey_last_dir_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let last_repo = super::mac_hotkey_last_repo_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows: Vec<super::LaunchHotkeyRow> = resolve_windows_hotkeys()
|
||||
.into_iter()
|
||||
.filter(|hk| windows_hotkeys::chord_to_win32(&hk.chord).is_some())
|
||||
.map(|hk| {
|
||||
let cwd = crate::launch_hotkeys::resolve_target_dir(&hk.dir, &last_dir, &last_repo);
|
||||
super::LaunchHotkeyRow {
|
||||
chord: hk.chord.canonical(),
|
||||
display: windows_hotkeys::display_windows(&hk.chord),
|
||||
label: hk.label.clone(),
|
||||
cwd_display: cwd.display().to_string(),
|
||||
self_dev: hk.self_dev,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let lines =
|
||||
super::launch_hotkey_notice_lines(&rows, &state.launch_hotkey_usage, state.launch_count)?;
|
||||
|
||||
Some(StartupHints::with_status_and_display(
|
||||
"Launch hotkeys available".to_string(),
|
||||
"Launch hotkeys",
|
||||
format!(
|
||||
"Configured Jcode launch hotkeys:\n{}\n\nThese fire system-wide.",
|
||||
lines.join("\n")
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reinstall the Windows hotkey listener after the `[launch_hotkeys]` config
|
||||
/// changed. No-op unless the user already configured the hotkey (we never
|
||||
/// install behind someone who opted out). Best-effort.
|
||||
pub(super) fn reinstall_windows_launch_hotkeys() {
|
||||
let state = SetupHintsState::load();
|
||||
if !state.hotkey_configured {
|
||||
return;
|
||||
}
|
||||
let use_alacritty = detect_terminal() == "alacritty" || is_alacritty_installed();
|
||||
match create_hotkey_shortcut(use_alacritty) {
|
||||
Ok(()) => jcode_logging::info("Reinstalled Windows launch hotkeys after config change"),
|
||||
Err(err) => jcode_logging::warn(&format!(
|
||||
"failed to reinstall Windows launch hotkeys: {err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_alacritty() -> Result<()> {
|
||||
eprintln!(" Installing Alacritty via winget...");
|
||||
eprintln!(" (Windows may ask for permission to install)\n");
|
||||
|
||||
let status = std::process::Command::new("winget")
|
||||
.args([
|
||||
"install",
|
||||
"-e",
|
||||
"--id",
|
||||
"Alacritty.Alacritty",
|
||||
"--accept-source-agreements",
|
||||
])
|
||||
.status()?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("winget install failed (exit code: {:?})", status.code())
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_hotkey(state: &mut SetupHintsState) -> bool {
|
||||
let terminal = detect_terminal();
|
||||
let using_alacritty = terminal == "alacritty" || is_alacritty_installed();
|
||||
|
||||
let terminal_name = if using_alacritty {
|
||||
"Alacritty"
|
||||
} else {
|
||||
"Windows Terminal"
|
||||
};
|
||||
|
||||
eprintln!("\x1b[36m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[1m💡 Set up Alt+; to launch jcode from anywhere?\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m Creates a global hotkey - no extra software needed. \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m Opens jcode in {:<39} \x1b[36m│\x1b[0m",
|
||||
format!("{}.", terminal_name)
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[32m[y]\x1b[0m Set up \x1b[90m[n]\x1b[0m Not now \x1b[90m[d]\x1b[0m Don't ask again \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!("\x1b[36m└─────────────────────────────────────────────────────────────┘\x1b[0m");
|
||||
eprint!("\x1b[36m >\x1b[0m ");
|
||||
let _ = io::stderr().flush();
|
||||
|
||||
let choice = read_choice();
|
||||
|
||||
match choice.as_str() {
|
||||
"y" | "yes" => {
|
||||
eprint!("\n");
|
||||
match create_hotkey_shortcut(using_alacritty) {
|
||||
Ok(()) => {
|
||||
state.hotkey_configured = true;
|
||||
let _ = state.save();
|
||||
eprintln!(
|
||||
" \x1b[32m✓\x1b[0m Created hotkey (\x1b[1mAlt+;\x1b[0m) → {} + jcode",
|
||||
terminal_name
|
||||
);
|
||||
eprintln!();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" \x1b[31m✗\x1b[0m Failed to create hotkey: {}", e);
|
||||
eprintln!(
|
||||
" You can set it up manually later with: \x1b[1mjcode setup-hotkey\x1b[0m"
|
||||
);
|
||||
eprintln!();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
"d" | "dont" => {
|
||||
state.hotkey_dismissed = true;
|
||||
let _ = state.save();
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_alacritty(state: &mut SetupHintsState) -> bool {
|
||||
let terminal = detect_terminal();
|
||||
|
||||
let current_terminal = match terminal {
|
||||
"windows-terminal" => "Windows Terminal",
|
||||
"wezterm" => "WezTerm",
|
||||
_ => "your current terminal",
|
||||
};
|
||||
|
||||
eprintln!("\x1b[36m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[1m💡 Alacritty: the fastest terminal for jcode\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m {:<55} \x1b[36m│\x1b[0m",
|
||||
format!("You're using {}.", current_terminal)
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m Alacritty is GPU-accelerated with the lowest latency. \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[36m│\x1b[0m \x1b[32m[y]\x1b[0m Install \x1b[90m[n]\x1b[0m Not now \x1b[90m[d]\x1b[0m Don't ask again \x1b[36m│\x1b[0m"
|
||||
);
|
||||
eprintln!("\x1b[36m└─────────────────────────────────────────────────────────────┘\x1b[0m");
|
||||
eprint!("\x1b[36m >\x1b[0m ");
|
||||
let _ = io::stderr().flush();
|
||||
|
||||
let choice = read_choice();
|
||||
|
||||
match choice.as_str() {
|
||||
"y" | "yes" => {
|
||||
eprint!("\n");
|
||||
if !is_winget_available() {
|
||||
eprintln!(" \x1b[33m⚠\x1b[0m winget not found. Install Alacritty manually:");
|
||||
eprintln!(" https://alacritty.org/");
|
||||
eprintln!();
|
||||
eprintln!(" Or install winget first: https://aka.ms/getwinget");
|
||||
eprintln!();
|
||||
return false;
|
||||
}
|
||||
|
||||
match install_alacritty() {
|
||||
Ok(()) => {
|
||||
state.alacritty_configured = true;
|
||||
let _ = state.save();
|
||||
eprintln!(" \x1b[32m✓\x1b[0m Alacritty installed!");
|
||||
|
||||
if state.hotkey_configured {
|
||||
eprintln!(" Updating hotkey to use Alacritty...");
|
||||
match create_hotkey_shortcut(true) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
" \x1b[32m✓\x1b[0m Hotkey updated: \x1b[1mAlt+;\x1b[0m → Alacritty + jcode"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" \x1b[33m⚠\x1b[0m Could not update hotkey: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" \x1b[31m✗\x1b[0m Failed to install Alacritty: {}", e);
|
||||
eprintln!(" Install manually: https://alacritty.org/");
|
||||
eprintln!();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
"d" | "dont" => {
|
||||
state.alacritty_dismissed = true;
|
||||
let _ = state.save();
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_try_it_out(installed_alacritty: bool) {
|
||||
eprintln!("\x1b[32m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m \x1b[1m✨ All set! Try it out:\x1b[0m \x1b[32m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m Press \x1b[1mAlt+;\x1b[0m from anywhere to launch jcode. \x1b[32m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m Inside jcode, \x1b[1mAlt+Shift+;\x1b[0m opens a new session here. \x1b[32m│\x1b[0m"
|
||||
);
|
||||
if installed_alacritty {
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m It will open in \x1b[1mAlacritty\x1b[0m for maximum performance. \x1b[32m│\x1b[0m"
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m"
|
||||
);
|
||||
eprintln!(
|
||||
"\x1b[32m│\x1b[0m \x1b[90m(Starting jcode normally in 3 seconds...)\x1b[0m \x1b[32m│\x1b[0m"
|
||||
);
|
||||
eprintln!("\x1b[32m└─────────────────────────────────────────────────────────────┘\x1b[0m");
|
||||
eprintln!();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
}
|
||||
|
||||
pub(super) fn maybe_show_windows_setup_hints(
|
||||
state: &mut SetupHintsState,
|
||||
startup_hints: Option<StartupHints>,
|
||||
) -> Option<StartupHints> {
|
||||
if state.launch_count % 3 != 0 {
|
||||
return startup_hints;
|
||||
}
|
||||
|
||||
let terminal = detect_terminal();
|
||||
let already_using_alacritty = terminal == "alacritty";
|
||||
|
||||
if already_using_alacritty {
|
||||
state.alacritty_configured = true;
|
||||
state.alacritty_dismissed = true;
|
||||
let _ = state.save();
|
||||
}
|
||||
|
||||
let wants_hotkey_nudge = !state.hotkey_configured && !state.hotkey_dismissed;
|
||||
let wants_alacritty_nudge =
|
||||
!state.alacritty_configured && !state.alacritty_dismissed && !already_using_alacritty;
|
||||
|
||||
// Stop pestering the user once we have shown the nudge prompt enough times,
|
||||
// even if they never explicitly chose "Don't ask again".
|
||||
if (wants_hotkey_nudge || wants_alacritty_nudge) && !state.nudge_budget_remaining() {
|
||||
return startup_hints;
|
||||
}
|
||||
|
||||
let mut did_setup_hotkey = false;
|
||||
let mut did_install_alacritty = false;
|
||||
|
||||
if wants_hotkey_nudge {
|
||||
state.record_nudge_shown();
|
||||
did_setup_hotkey = nudge_hotkey(state);
|
||||
}
|
||||
|
||||
if wants_alacritty_nudge {
|
||||
state.record_nudge_shown();
|
||||
did_install_alacritty = nudge_alacritty(state);
|
||||
}
|
||||
|
||||
if did_setup_hotkey || (did_install_alacritty && state.hotkey_configured) {
|
||||
prompt_try_it_out(did_install_alacritty);
|
||||
}
|
||||
|
||||
startup_hints
|
||||
}
|
||||
|
||||
pub(super) fn run_setup_hotkey_windows() -> Result<()> {
|
||||
let mut state = SetupHintsState::load();
|
||||
let terminal = detect_terminal();
|
||||
let already_using_alacritty = terminal == "alacritty";
|
||||
|
||||
eprintln!("\x1b[1mjcode setup-hotkey\x1b[0m");
|
||||
eprintln!();
|
||||
|
||||
eprintln!(
|
||||
" Detected terminal: {}",
|
||||
match terminal {
|
||||
"windows-terminal" => "Windows Terminal",
|
||||
"wezterm" => "WezTerm",
|
||||
"alacritty" => "Alacritty",
|
||||
_ => "Unknown",
|
||||
}
|
||||
);
|
||||
|
||||
if is_alacritty_installed() && !already_using_alacritty {
|
||||
eprintln!(" Alacritty: \x1b[32minstalled\x1b[0m");
|
||||
} else if already_using_alacritty {
|
||||
eprintln!(" Alacritty: \x1b[32mactive\x1b[0m");
|
||||
} else {
|
||||
eprintln!(" Alacritty: \x1b[90mnot installed\x1b[0m");
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
let mut installed_alacritty = false;
|
||||
if !already_using_alacritty && !is_alacritty_installed() {
|
||||
eprintln!(
|
||||
" Alacritty is the fastest terminal emulator (GPU-accelerated, lowest latency)."
|
||||
);
|
||||
eprint!(" Install Alacritty? \x1b[32m[y]\x1b[0m/\x1b[90m[n]\x1b[0m: ");
|
||||
let _ = io::stderr().flush();
|
||||
let choice = read_choice();
|
||||
if choice == "y" || choice == "yes" {
|
||||
if !is_winget_available() {
|
||||
eprintln!("\n \x1b[33m⚠\x1b[0m winget not found. Install Alacritty manually:");
|
||||
eprintln!(" https://alacritty.org/\n");
|
||||
} else {
|
||||
match install_alacritty() {
|
||||
Ok(()) => {
|
||||
state.alacritty_configured = true;
|
||||
installed_alacritty = true;
|
||||
eprintln!(" \x1b[32m✓\x1b[0m Alacritty installed!\n");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" \x1b[31m✗\x1b[0m Install failed: {}\n", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
let use_alacritty = already_using_alacritty || is_alacritty_installed();
|
||||
let terminal_name = if use_alacritty {
|
||||
"Alacritty"
|
||||
} else {
|
||||
"Windows Terminal"
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
" Setting up global launch hotkeys → {} + jcode...",
|
||||
terminal_name
|
||||
);
|
||||
|
||||
match create_hotkey_shortcut(use_alacritty) {
|
||||
Ok(()) => {
|
||||
state.hotkey_configured = true;
|
||||
let _ = state.save();
|
||||
eprintln!(" \x1b[32m✓\x1b[0m Created launch hotkeys");
|
||||
eprintln!();
|
||||
eprintln!(" Press these anywhere, system-wide:");
|
||||
for hk in resolve_windows_hotkeys() {
|
||||
if windows_hotkeys::chord_to_win32(&hk.chord).is_some() {
|
||||
let suffix = if hk.self_dev { " [self-dev]" } else { "" };
|
||||
eprintln!(
|
||||
" \x1b[1m{}\x1b[0m → {}{}",
|
||||
windows_hotkeys::display_windows(&hk.chord),
|
||||
hk.label,
|
||||
suffix
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
prompt_try_it_out(installed_alacritty);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" \x1b[31m✗\x1b[0m Failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn create_windows_desktop_shortcut(state: &mut SetupHintsState) -> Result<()> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe_path = exe.to_string_lossy();
|
||||
|
||||
let (target, args) = if is_alacritty_installed() {
|
||||
let alacritty = find_alacritty_path().unwrap_or_else(|| "alacritty".to_string());
|
||||
(alacritty, format!("-e \"{}\"", exe_path))
|
||||
} else {
|
||||
(exe_path.to_string(), String::new())
|
||||
};
|
||||
|
||||
let desktop_dir = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".into());
|
||||
let shortcut_path = format!("{}\\Desktop\\jcode.lnk", desktop_dir);
|
||||
|
||||
let ps_script = format!(
|
||||
r#"
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut("{shortcut_path}")
|
||||
$shortcut.TargetPath = "{target}"
|
||||
$shortcut.Arguments = '{args}'
|
||||
$shortcut.Description = "jcode - AI coding agent"
|
||||
$shortcut.Save()
|
||||
Write-Output "OK"
|
||||
"#,
|
||||
shortcut_path = shortcut_path,
|
||||
target = target,
|
||||
args = args,
|
||||
);
|
||||
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps_script])
|
||||
.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.contains("OK") {
|
||||
state.desktop_shortcut_created = true;
|
||||
let _ = state.save();
|
||||
jcode_logging::info(&format!("Created desktop shortcut: {}", shortcut_path));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user